fix(terminal): keep sessions alive without hard timeouts

This commit is contained in:
Andras Bacsai
2026-05-31 21:52:46 +02:00
parent 38e855b20e
commit b46d8e2601
9 changed files with 75 additions and 58 deletions
+3 -2
View File
@@ -69,7 +69,7 @@ class SshMultiplexingHelper
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest); return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
} }
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
{ {
if ($server->settings->force_disabled) { if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.'); throw new \RuntimeException('Server is disabled.');
@@ -80,7 +80,8 @@ class SshMultiplexingHelper
self::validateSshKey($server->privateKey); self::validateSshKey($server->privateKey);
$sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh '; $commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
if (! $disableMultiplexing && self::isMultiplexingEnabled()) { if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
$sshCommand .= self::multiplexingOptions($server); $sshCommand .= self::multiplexingOptions($server);
+29 -2
View File
@@ -12,6 +12,8 @@ class Terminal extends Component
{ {
public bool $hasShell = true; public bool $hasShell = true;
public bool $isTerminalConnected = false;
private function checkShellAvailability(Server $server, string $container): bool private function checkShellAvailability(Server $server, string $container): bool
{ {
$escapedContainer = escapeshellarg($container); $escapedContainer = escapeshellarg($container);
@@ -65,12 +67,20 @@ class Terminal extends Component
$dockerCommand = "sudo {$dockerCommand}"; $dockerCommand = "sudo {$dockerCommand}";
} }
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand); $command = SshMultiplexingHelper::generateSshCommand(
$server,
$dockerCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} else { } else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '. 'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand); $command = SshMultiplexingHelper::generateSshCommand(
$server,
$shellCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} }
// ssh command is sent back to frontend then to websocket // ssh command is sent back to frontend then to websocket
// this is done because the websocket connection is not available here // this is done because the websocket connection is not available here
@@ -84,6 +94,23 @@ class Terminal extends Component
$this->dispatch('send-back-command', $command); $this->dispatch('send-back-command', $command);
} }
#[On('terminalConnected')]
public function markTerminalConnected(): void
{
$this->isTerminalConnected = true;
}
#[On('terminalDisconnected')]
public function markTerminalDisconnected(): void
{
$this->isTerminalConnected = false;
}
public function keepTerminalPageAlive(): void
{
$this->isTerminalConnected = true;
}
public function render() public function render()
{ {
return view('livewire.project.shared.terminal'); return view('livewire.project.shared.terminal');
+1
View File
@@ -35,6 +35,7 @@ return [
'protocol' => env('TERMINAL_PROTOCOL'), 'protocol' => env('TERMINAL_PROTOCOL'),
'host' => env('TERMINAL_HOST'), 'host' => env('TERMINAL_HOST'),
'port' => env('TERMINAL_PORT'), 'port' => env('TERMINAL_PORT'),
'command_timeout' => 0,
], ],
'pusher' => [ 'pusher' => [
+2 -30
View File
@@ -63,8 +63,8 @@ function createHttpError(response) {
} }
const userSessions = new Map(); const userSessions = new Map();
const terminalDebugEnabled = ['1', 'true', 'yes'].includes( const terminalDebugEnabled = ['local', 'development'].includes(
String(process.env.TERMINAL_DEBUG || '').toLowerCase() String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
); );
function logTerminal(level, message, context = {}) { function logTerminal(level, message, context = {}) {
@@ -154,7 +154,6 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
const HEARTBEAT_INTERVAL_MS = 30000; const HEARTBEAT_INTERVAL_MS = 30000;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
wss.on('connection', async (ws, req) => { wss.on('connection', async (ws, req) => {
ws.isAlive = true; ws.isAlive = true;
@@ -168,7 +167,6 @@ wss.on('connection', async (ws, req) => {
ptyProcess: null, ptyProcess: null,
isActive: false, isActive: false,
authorizedIPs: [], authorizedIPs: [],
lastActivityAt: Date.now(),
authReady: false, authReady: false,
pendingMessages: [], pendingMessages: [],
}; };
@@ -260,29 +258,6 @@ const heartbeat = setInterval(() => {
} catch (_) { } catch (_) {
// ignore — close handler will follow // ignore — close handler will follow
} }
const session = ws.userId ? userSessions.get(ws.userId) : null;
if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
const idleMs = Date.now() - session.lastActivityAt;
logTerminal('warn', 'Closing terminal session due to idle timeout.', {
userId: ws.userId,
idleMs,
idleTimeoutMs: IDLE_TIMEOUT_MS,
});
try {
ws.send('idle-timeout');
} catch (_) {
// ignore — close still attempted below
}
killPtyProcess(ws.userId);
setTimeout(() => {
try {
ws.close(1000, 'Idle timeout');
} catch (_) {
// ignore — already closed
}
}, 100);
}
}); });
}, HEARTBEAT_INTERVAL_MS); }, HEARTBEAT_INTERVAL_MS);
@@ -290,11 +265,9 @@ wss.on('close', () => clearInterval(heartbeat));
const messageHandlers = { const messageHandlers = {
message: (session, data) => { message: (session, data) => {
session.lastActivityAt = Date.now();
session.ptyProcess.write(data); session.ptyProcess.write(data);
}, },
resize: (session, { cols, rows }) => { resize: (session, { cols, rows }) => {
session.lastActivityAt = Date.now();
cols = cols > 0 ? cols : 80; cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30; rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows) session.ptyProcess.resize(cols, rows)
@@ -420,7 +393,6 @@ async function handleCommand(ws, command, userId) {
userSession.ptyProcess = ptyProcess; userSession.ptyProcess = ptyProcess;
userSession.isActive = true; userSession.isActive = true;
userSession.lastActivityAt = Date.now();
ws.send('pty-ready'); ws.send('pty-ready');
+2 -6
View File
@@ -1261,8 +1261,7 @@
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
@@ -1752,7 +1751,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -1946,8 +1944,7 @@
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
@@ -1992,7 +1989,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
+1 -10
View File
@@ -44,7 +44,7 @@ export function initializeTerminalComponent() {
pendingCommand: null, pendingCommand: null,
// Last successfully sent SSH command — replayed after a transient reconnect // Last successfully sent SSH command — replayed after a transient reconnect
// so the PTY respawns automatically. Cleared on intentional terminations // so the PTY respawns automatically. Cleared on intentional terminations
// (pty-exited, idle-timeout, unprocessable). // (pty-exited, unprocessable).
lastSentCommand: null, lastSentCommand: null,
// Resize handling // Resize handling
resizeObserver: null, resizeObserver: null,
@@ -462,15 +462,6 @@ export function initializeTerminalComponent() {
// Notify parent component that terminal disconnected // Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected'); this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'idle-timeout') {
this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
this.terminalActive = false;
if (this.term) {
this.term.reset();
}
this.commandBuffer = '';
this.lastSentCommand = null;
this.$wire.dispatch('terminalDisconnected');
} else if ( } else if (
typeof event.data === 'string' && typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
@@ -1,4 +1,7 @@
<div id="terminal-container" x-data="terminalData()"> <div id="terminal-container" x-data="terminalData()">
@if ($isTerminalConnected)
<div class="hidden" aria-hidden="true" wire:poll.keep-alive.30s="keepTerminalPageAlive"></div>
@endif
@if (!$hasShell) @if (!$hasShell)
<div class="flex pt-4 items-center justify-center w-full py-4 mx-auto"> <div class="flex pt-4 items-center justify-center w-full py-4 mx-auto">
<div class="p-4 w-full rounded-sm border dark:bg-coolgray-100 dark:border-coolgray-300"> <div class="p-4 w-full rounded-sm border dark:bg-coolgray-100 dark:border-coolgray-300">
@@ -58,22 +58,35 @@ it('uses a fast probe timeout when the tab regains visibility', function () {
->toContain("'Visibility-resume timeout'"); ->toContain("'Visibility-resume timeout'");
}); });
it('closes idle terminal sessions after 30 minutes on the server', function () { it('does not hard close terminal sessions after 30 minutes on the server', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer) expect($terminalServer)
->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000') ->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
->toContain('lastActivityAt') ->not->toContain("ws.send('idle-timeout');")
->toContain("ws.send('idle-timeout');") ->not->toContain("ws.close(1000, 'Idle timeout');");
->toContain("ws.close(1000, 'Idle timeout');");
}); });
it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () { it('does not close the client terminal from an idle-timeout sentinel', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js')); $terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient) expect($terminalClient)
->toContain("event.data === 'idle-timeout'") ->not->toContain("event.data === 'idle-timeout'")
->toContain('Terminal closed after 30 minutes of inactivity.'); ->not->toContain('Terminal closed after 30 minutes of inactivity.');
});
it('keeps Livewire alive in background tabs while a terminal is connected', function () {
$terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
$terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
expect($terminalComponent)
->toContain('public bool $isTerminalConnected = false;')
->toContain("#[On('terminalConnected')]")
->toContain('public function markTerminalConnected(): void')
->toContain('public function keepTerminalPageAlive(): void')
->and($terminalView)
->toContain('@if ($isTerminalConnected)')
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
}); });
it('replays the last command on reconnect so the PTY respawns automatically', function () { it('replays the last command on reconnect so the PTY respawns automatically', function () {
+13
View File
@@ -73,6 +73,19 @@ it('adds native openssh multiplexing options to ssh commands', function () {
Process::assertNothingRan(); Process::assertNothingRan();
}); });
it('can generate terminal ssh commands without a hard command timeout', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
$command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
expect($command)
->toStartWith('ssh ')
->not->toStartWith('timeout ')
->not->toContain('timeout 3600 ssh');
});
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () { it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
config(['constants.ssh.mux_enabled' => true]); config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer(); $server = makeMuxServer();