fix(terminal): keep long-running sessions connected (#10482)

This commit is contained in:
Andras Bacsai 2026-06-01 10:14:18 +02:00 committed by GitHub
commit 7d9cf6f815
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 236 additions and 65 deletions

View file

@ -69,7 +69,7 @@ public static function generateScpCommand(Server $server, string $source, string
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) {
throw new \RuntimeException('Server is disabled.');
@ -80,7 +80,8 @@ public static function generateSshCommand(Server $server, string $command, bool
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()) {
$sshCommand .= self::multiplexingOptions($server);

View file

@ -12,6 +12,8 @@ class Terminal extends Component
{
public bool $hasShell = true;
public bool $isTerminalConnected = false;
private function checkShellAvailability(Server $server, string $container): bool
{
$escapedContainer = escapeshellarg($container);
@ -65,12 +67,20 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$dockerCommand = "sudo {$dockerCommand}";
}
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
$command = SshMultiplexingHelper::generateSshCommand(
$server,
$dockerCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; 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
// this is done because the websocket connection is not available here
@ -84,6 +94,23 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$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()
{
return view('livewire.project.shared.terminal');

View file

@ -35,6 +35,7 @@
'protocol' => env('TERMINAL_PROTOCOL'),
'host' => env('TERMINAL_HOST'),
'port' => env('TERMINAL_PORT'),
'command_timeout' => 0,
],
'pusher' => [

View file

@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
pull_policy: always
container_name: coolify-realtime
restart: always

View file

@ -8,6 +8,7 @@ import {
extractSshArgs,
extractTargetHost,
extractTimeout,
getTerminalSessionTimeout,
isAuthorizedTargetHost,
} from './terminal-utils.js';
@ -63,9 +64,11 @@ function createHttpError(response) {
}
const userSessions = new Map();
const terminalDebugEnabled = ['1', 'true', 'yes'].includes(
String(process.env.TERMINAL_DEBUG || '').toLowerCase()
);
const envName = String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase();
const debugOverride = String(process.env.TERMINAL_DEBUG || '').toLowerCase();
const terminalDebugEnabled =
['local', 'development'].includes(envName)
|| ['1', 'true', 'yes', 'on'].includes(debugOverride);
function logTerminal(level, message, context = {}) {
if (!terminalDebugEnabled) {
@ -154,7 +157,6 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
const HEARTBEAT_INTERVAL_MS = 30000;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
wss.on('connection', async (ws, req) => {
ws.isAlive = true;
@ -168,9 +170,9 @@ wss.on('connection', async (ws, req) => {
ptyProcess: null,
isActive: false,
authorizedIPs: [],
lastActivityAt: Date.now(),
authReady: false,
pendingMessages: [],
terminalSessionTimer: null,
};
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
@ -260,29 +262,6 @@ const heartbeat = setInterval(() => {
} catch (_) {
// 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);
@ -290,11 +269,9 @@ wss.on('close', () => clearInterval(heartbeat));
const messageHandlers = {
message: (session, data) => {
session.lastActivityAt = Date.now();
session.ptyProcess.write(data);
},
resize: (session, { cols, rows }) => {
session.lastActivityAt = Date.now();
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows)
@ -365,8 +342,14 @@ async function handleCommand(ws, command, userId) {
}
}
if (userSession.terminalSessionTimer) {
clearTimeout(userSession.terminalSessionTimer);
userSession.terminalSessionTimer = null;
}
const commandString = command[0].split('\n').join(' ');
const timeout = extractTimeout(commandString);
const commandTimeout = extractTimeout(commandString);
const terminalSessionTimeout = getTerminalSessionTimeout();
const sshArgs = extractSshArgs(commandString);
const hereDocContent = extractHereDocContent(commandString);
@ -375,7 +358,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Parsed terminal command metadata.', {
userId,
targetHost,
timeout,
commandTimeout,
terminalSessionTimeout,
sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [],
});
@ -414,13 +398,13 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Spawning PTY process for terminal session.', {
userId,
targetHost,
timeout,
commandTimeout,
terminalSessionTimeout,
});
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
userSession.lastActivityAt = Date.now();
ws.send('pty-ready');
@ -437,13 +421,16 @@ async function handleCommand(ws, command, userId) {
});
ws.send('pty-exited');
userSession.isActive = false;
if (userSession.terminalSessionTimer) {
clearTimeout(userSession.terminalSessionTimer);
userSession.terminalSessionTimer = null;
}
});
if (timeout) {
setTimeout(async () => {
await killPtyProcess(userId);
}, timeout * 1000);
}
userSession.terminalSessionTimer = setTimeout(async () => {
await killPtyProcess(userId);
}, terminalSessionTimeout * 1000);
}
async function handleError(err, userId) {
@ -485,6 +472,11 @@ async function killPtyProcess(userId) {
setTimeout(() => {
if (!session.isActive || !session.ptyProcess) {
if (session.terminalSessionTimer) {
clearTimeout(session.terminalSessionTimer);
session.terminalSessionTimer = null;
}
logTerminal('log', 'PTY process terminated successfully.', {
userId,
killAttempts,

View file

@ -1,3 +1,9 @@
export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
export function getTerminalSessionTimeout() {
return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS;
}
export function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;

View file

@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
extractSshArgs,
extractTargetHost,
getTerminalSessionTimeout,
isAuthorizedTargetHost,
normalizeHostForAuthorization,
} from './terminal-utils.js';
@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
});
test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => {
assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
});

View file

@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
pull_policy: always
container_name: coolify-realtime
restart: always

View file

@ -0,0 +1,22 @@
export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60;
export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60;
export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60;
export function formatTerminalSessionRemainingTime(seconds) {
const remainingSeconds = Math.max(0, Math.ceil(seconds));
if (remainingSeconds === 0) {
return 'expired';
}
const totalMinutes = Math.floor(remainingSeconds / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const secondsPart = remainingSeconds % 60;
if (hours === 0) {
return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`;
}
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`;
}

View file

@ -1,5 +1,11 @@
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import {
MAX_TERMINAL_SESSION_SECONDS,
TERMINAL_SESSION_DANGER_SECONDS,
TERMINAL_SESSION_WARNING_SECONDS,
formatTerminalSessionRemainingTime,
} from './terminal-session-timer.js';
import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
@ -44,7 +50,7 @@ export function initializeTerminalComponent() {
pendingCommand: null,
// Last successfully sent SSH command — replayed after a transient reconnect
// so the PTY respawns automatically. Cleared on intentional terminations
// (pty-exited, idle-timeout, unprocessable).
// (pty-exited, unprocessable).
lastSentCommand: null,
// Resize handling
resizeObserver: null,
@ -52,6 +58,9 @@ export function initializeTerminalComponent() {
// Visibility handling - prevent disconnects when tab loses focus
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
terminalSessionStartedAt: null,
terminalSessionRemainingSeconds: null,
terminalSessionCountdownInterval: null,
init() {
this.setupTerminal();
@ -135,6 +144,7 @@ export function initializeTerminalComponent() {
this.clearAllTimers();
this.connectionState = 'disconnected';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
@ -157,11 +167,68 @@ export function initializeTerminalComponent() {
}
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearTimeout(timer));
if (this.terminalSessionCountdownInterval) {
clearInterval(this.terminalSessionCountdownInterval);
}
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
this.pingTimeoutId = null;
this.resizeTimeout = null;
this.terminalSessionCountdownInterval = null;
},
resetTerminalSessionCountdown() {
if (this.terminalSessionCountdownInterval) {
clearInterval(this.terminalSessionCountdownInterval);
}
this.terminalSessionStartedAt = null;
this.terminalSessionRemainingSeconds = null;
this.terminalSessionCountdownInterval = null;
},
startTerminalSessionCountdown() {
this.resetTerminalSessionCountdown();
this.terminalSessionStartedAt = Date.now();
this.updateTerminalSessionCountdown();
this.terminalSessionCountdownInterval = setInterval(() => {
this.updateTerminalSessionCountdown();
}, 1000);
},
updateTerminalSessionCountdown() {
if (!this.terminalSessionStartedAt) {
this.terminalSessionRemainingSeconds = null;
return;
}
const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000;
this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds);
},
terminalSessionRemainingLabel() {
if (this.terminalSessionRemainingSeconds === null) {
return '';
}
return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`;
},
terminalSessionTimerClass() {
if (this.terminalSessionRemainingSeconds === null) {
return 'text-neutral-300 bg-black/70 border-white/10';
}
if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) {
return 'text-red-200 bg-red-950/80 border-red-500/40';
}
if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) {
return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40';
}
return 'text-neutral-300 bg-black/70 border-white/10';
},
resetTerminal() {
@ -181,6 +248,7 @@ export function initializeTerminalComponent() {
this.paused = false;
this.commandBuffer = '';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
@ -328,6 +396,7 @@ export function initializeTerminalComponent() {
this.connectionState = 'disconnected';
this.clearAllTimers();
this.resetTerminalSessionCountdown();
// Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) {
@ -424,6 +493,7 @@ export function initializeTerminalComponent() {
}
}
this.terminalActive = true;
this.startTerminalSessionCountdown();
this.term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
@ -450,27 +520,20 @@ export function initializeTerminalComponent() {
if (this.term) this.term.reset();
this.terminalActive = false;
this.lastSentCommand = null;
this.resetTerminalSessionCountdown();
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') {
this.terminalActive = false;
this.resetTerminalSessionCountdown();
this.term.reset();
this.commandBuffer = '';
this.lastSentCommand = null;
// Notify parent component that terminal disconnected
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 (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
@ -478,6 +541,7 @@ export function initializeTerminalComponent() {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data);
this.terminalActive = false;
this.resetTerminalSessionCountdown();
} else {
try {
this.pendingWrites++;

View file

@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_TERMINAL_SESSION_SECONDS,
formatTerminalSessionRemainingTime,
} from './terminal-session-timer.js';
test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => {
assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60);
assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s');
assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s');
assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s');
assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s');
assert.equal(formatTerminalSessionRemainingTime(0), 'expired');
});

View file

@ -1,4 +1,7 @@
<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)
<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">
@ -20,6 +23,11 @@
<div x-ref="terminalWrapper"
:class="fullscreen ? 'fullscreen !bg-black' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
<!-- Terminal container -->
<div x-show="terminalActive" x-cloak class="mb-2 flex justify-start">
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
:class="terminalSessionTimerClass()" x-text="terminalSessionRemainingLabel()">
</div>
</div>
<div id="terminal" wire:ignore
:class="fullscreen ? 'px-2 py-1 h-full bg-black' : 'px-2 py-1 rounded-sm bg-black'" x-show="terminalActive">
</div>

View file

@ -58,22 +58,35 @@
->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'));
expect($terminalServer)
->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
->toContain('lastActivityAt')
->toContain("ws.send('idle-timeout');")
->toContain("ws.close(1000, 'Idle timeout');");
->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
->not->toContain("ws.send('idle-timeout');")
->not->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'));
expect($terminalClient)
->toContain("event.data === 'idle-timeout'")
->toContain('Terminal closed after 30 minutes of inactivity.');
->not->toContain("event.data === 'idle-timeout'")
->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 () {

View file

@ -73,6 +73,19 @@ function makeMuxServer(): Server
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 () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();