diff --git a/electron/main.cjs b/electron/main.cjs index 70b4a8a..c5b66d9 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -3006,7 +3006,9 @@ function deliverDesktopExitedSession(id, exited) { exitCode: exited.exitCode, signal: exited.signal, exitClaimToken: exited.exitClaimToken, - exitDeliveryToken: exited.exitDeliveryToken + exitDeliveryToken: exited.exitDeliveryToken, + hostGeneration: exited.hostGeneration, + outputRevision: exited.outputRevision }); return true; } @@ -3116,7 +3118,8 @@ function sanitizeMobileWorkspace(value) { lastActivityAt: Math.max(0, Number(session?.lastActivityAt) || Number(session?.lastResponseAt) || 0), notified: Boolean(session?.notified), inputRequired: Boolean(session?.inputRequired), - busy: Boolean(session?.busy) + busy: Boolean(session?.busy), + exited: Boolean(session?.exited) })).filter((session) => session.id) : []; const activeId = String(value?.activeId || '').slice(0, 100); return { groups, sessions: workspaceSessions, activeId: workspaceSessions.some((session) => session.id === activeId) ? activeId : '' }; @@ -3133,8 +3136,10 @@ function mobileSessionSnapshot() { groupId: metadata.get(id)?.groupId || '', notified: Boolean(metadata.get(id)?.notified), inputRequired: Boolean(metadata.get(id)?.inputRequired), - busy: mobileExitedSessions.has(id) ? false : Boolean(metadata.get(id)?.busy), - exited: mobileExitedSessions.has(id) + busy: mobileExitedSessions.has(id) || metadata.get(id)?.exited + ? false + : Boolean(metadata.get(id)?.busy), + exited: mobileExitedSessions.has(id) || Boolean(metadata.get(id)?.exited) })); } @@ -4084,6 +4089,92 @@ async function closeSession(id) { } } +async function cleanupStoppedSession(id, hostGeneration, snapshot = {}, { retainMobile = true } = {}) { + const sessionId = String(id || ''); + const generation = String(hostGeneration || ''); + if (!sessionId) return false; + + if (retainMobile && !mobileExitedSessions.has(sessionId)) { + mobileExitedSessions.set(sessionId, { + data: String(snapshot.terminalState || '').slice(-MOBILE_RESTORED_STATE_MAX_CHARACTERS), + exitCode: null, + revision: 1, + cols: Math.min(1_000, Math.max(2, Math.floor(Number(snapshot.cols) || 80))), + rows: Math.min(500, Math.max(1, Math.floor(Number(snapshot.rows) || 24))), + source: 'raw' + }); + while (mobileExitedSessions.size > MOBILE_EXITED_SESSION_LIMIT) { + mobileExitedSessions.delete(mobileExitedSessions.keys().next().value); + } + broadcastMobileSnapshot(); + } + + if (!generation) { + if (!retainMobile) mobileExitedSessions.delete(sessionId); + broadcastMobileSnapshot(); + return true; + } + + const liveSession = sessions.get(sessionId); + if (liveSession) { + if (!liveSession.windowsHosted + || String(liveSession.processHandle?.generation || '') !== generation) { + throw new Error('The terminal generation changed before stopped state could be cleaned up.'); + } + await closeSession(sessionId); + } + + const exitedSession = desktopExitedSessions.get(sessionId); + if (!liveSession && exitedSession) { + const exitedGeneration = String( + exitedSession.hostGeneration || exitedSession.processHandle?.generation || '' + ); + if (exitedGeneration !== generation) { + throw new Error('The terminal generation changed before stopped state could be cleaned up.'); + } + await exitedSession.processHandle.kill(); + if (desktopExitedSessions.get(sessionId) === exitedSession) { + desktopExitedSessions.delete(sessionId); + } + } else if (!liveSession) { + if (process.platform === 'win32') { + const client = await getWindowsPtyHostClient(); + await client.cleanupExitedSession(sessionId, generation); + } + } + if (!retainMobile) mobileExitedSessions.delete(sessionId); + broadcastMobileSnapshot(); + return true; +} + +async function restoreStoppedSession(options = {}) { + const id = String(options.id || ''); + if (!id) throw new Error('A session id is required.'); + const tmux = tmuxRuntime(); + const tmuxSession = tmux ? tmuxSessionName(id) : ''; + if (tmux && tmuxSessionExists(tmux, tmuxSession)) { + desktopExitedSessions.delete(id); + return createSession(options); + } + + const confirmedCheckpoint = options.exitCheckpointConfirmed === true; + if (process.platform === 'win32' && !confirmedCheckpoint) { + return createSession(options); + } + + desktopExitedSessions.delete(id); + try { + await cleanupStoppedSession(id, options.checkpointGeneration, { + terminalState: options.mobileTerminalState, + cols: options.terminalStateCols, + rows: options.terminalStateRows + }); + } catch { + // The persisted stopped pane remains usable and closing it retries cleanup. + } + return { id, stopped: true, exited: true }; +} + function detachAllSessions() { if (detachAllSessionsPromise) return detachAllSessionsPromise; terminalSessionDrainActive = true; @@ -4145,6 +4236,7 @@ function registerIpc() { return true; }); ipcMain.handle('terminal:create', (_event, options) => createSession(options)); + ipcMain.handle('terminal:restore-stopped', (_event, options) => restoreStoppedSession(options)); ipcMain.handle('terminal:renderer-ready', (_event, id) => markTerminalRendererReady(String(id || ''))); ipcMain.on('terminal:write', (_event, { id, data }) => { sessions.get(id)?.processHandle.write(data); @@ -4229,6 +4321,11 @@ function registerIpc() { ); return operation; }); + ipcMain.handle('terminal:cleanup-stopped', (_event, { + id, hostGeneration, terminalState, cols, rows, retainMobile = true + } = {}) => ( + cleanupStoppedSession(id, hostGeneration, { terminalState, cols, rows }, { retainMobile }) + )); ipcMain.handle('terminal:get-state', (_event, id) => { const session = sessions.get(id); if (!session) return null; diff --git a/electron/preload.cjs b/electron/preload.cjs index 33c206e..34dd234 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -13,12 +13,16 @@ contextBridge.exposeInMainWorld('sideTerm', { saveTerminalCheckpoint: (checkpoint) => ipcRenderer.invoke('workspace:save-terminal-checkpoint', checkpoint), pruneTerminalCheckpoints: (activeIds) => ipcRenderer.invoke('workspace:prune-terminal-checkpoints', activeIds), createSession: (options) => ipcRenderer.invoke('terminal:create', options), + restoreStoppedSession: (options) => ipcRenderer.invoke('terminal:restore-stopped', options), markRendererReady: (id) => ipcRenderer.invoke('terminal:renderer-ready', id), write: (id, data) => ipcRenderer.send('terminal:write', { id, data }), resize: (id, cols, rows) => ipcRenderer.send('terminal:resize', { id, cols, rows }), scroll: (id, amount) => ipcRenderer.send('terminal:scroll', { id, amount }), armGithubPush: (id, details) => ipcRenderer.send('github:push-armed', { id, details }), close: (id) => ipcRenderer.invoke('terminal:close', id), + cleanupStopped: (id, hostGeneration, snapshot = {}, retainMobile = true) => ipcRenderer.invoke('terminal:cleanup-stopped', { + ...snapshot, id, hostGeneration, retainMobile + }), getSessionState: (id) => ipcRenderer.invoke('terminal:get-state', id), onData: (callback) => subscribe('terminal:data', callback), acknowledgeData: (id, byteLength, replayClaimToken = '', replayDeliveryToken = '', rendererDataDeliveryToken = '', exitClaimToken = '', exitDeliveryToken = '') => ipcRenderer.send('terminal:data-ack', { diff --git a/electron/sessions/reattach.cjs b/electron/sessions/reattach.cjs index 0cdd066..98f2495 100644 --- a/electron/sessions/reattach.cjs +++ b/electron/sessions/reattach.cjs @@ -34,7 +34,12 @@ function reattachSession(id, session, { cols = 100, rows = 30 } = {}) { const nextRows = positiveInteger(rows, 30); session.cols = Math.max(2, nextCols); session.rows = nextRows; - session.processHandle.resize(session.cols, session.rows); + try { + session.processHandle.resize(session.cols, session.rows); + } catch { + // A direct node-pty process can exit between lookup and resize. Its queued + // exit event remains authoritative and is delivered after renderer-ready. + } return sessionDetails(id, session, { reattached: true }); } diff --git a/electron/sessions/windows-pty-client.cjs b/electron/sessions/windows-pty-client.cjs index 7f76d70..4b33dc3 100644 --- a/electron/sessions/windows-pty-client.cjs +++ b/electron/sessions/windows-pty-client.cjs @@ -445,6 +445,15 @@ class WindowsPtyHostClient { return handle; } + cleanupExitedSession(id, generation) { + const sessionId = String(id || ''); + const hostGeneration = String(generation || ''); + if (!sessionId || !hostGeneration) { + return Promise.reject(new Error('A session id and host generation are required.')); + } + return this.request('kill', { id: sessionId, generation: hostGeneration }); + } + shutdownIfIdle() { return this.request('shutdown-if-idle'); } diff --git a/src/main.js b/src/main.js index e351880..7a83f34 100644 --- a/src/main.js +++ b/src/main.js @@ -1204,6 +1204,8 @@ async function persistWorkspaceSnapshot({ manualTitle: session.manualTitle, shell: session.shell, cwd: session.cwd, + exited: Boolean(session.exitObserved), + exitCheckpointConfirmed: Boolean(session.exitCheckpointConfirmed), history: terminalHistory(session.terminal), terminalState: terminalCheckpoint.state, mobileTerminalState: terminalCheckpoint.mobileState, @@ -1312,7 +1314,8 @@ async function persistWorkspaceSnapshot({ lastActivityAt: session.lastResponseAt || session.createdAt, notified: session.notified, inputRequired: session.inputRequired, - busy: sessions.get(session.id)?.busy + busy: sessions.get(session.id)?.busy, + exited: Boolean(session.exited) })) }); return durableSave; @@ -1369,7 +1372,11 @@ function flushTerminalCheckpointAcknowledgements({ forceSave = false } = {}) { for (const entry of activeTerminalCheckpointAcknowledgements(activeAcknowledgements, sessions)) { const session = sessions.get(entry.id); if (persistedCheckpointCoversDelivery(session, entry)) { - entry.acknowledge(); + try { + await entry.acknowledge(); + } catch { + unsatisfied.push(entry); + } } else { unsatisfied.push(entry); } @@ -3019,6 +3026,8 @@ async function addSession(cwd, options = {}) { pane.dataset.sessionId = id; terminalStack.append(pane); + const restoredStateCols = Math.min(1_000, Math.max(2, Math.floor(Number(options.terminalStateCols) || 80))); + const restoredStateRows = Math.min(500, Math.max(1, Math.floor(Number(options.terminalStateRows) || 24))); const terminal = new Terminal({ allowProposedApi: false, convertEol: true, @@ -3026,8 +3035,8 @@ async function addSession(cwd, options = {}) { disableStdin: true, cursorStyle: 'bar', cursorWidth: 2, - cols: Math.min(1_000, Math.max(2, Math.floor(Number(options.terminalStateCols) || 80))), - rows: Math.min(500, Math.max(1, Math.floor(Number(options.terminalStateRows) || 24))), + cols: restoredStateCols, + rows: restoredStateRows, fontFamily: "'Cascadia Code', 'CaskaydiaCove Nerd Font', 'Ubuntu Mono', monospace", fontSize: 15, fontWeight: '400', @@ -3070,7 +3079,9 @@ async function addSession(cwd, options = {}) { serializeAddon, pane, item: null, - exited: false, + exited: Boolean(options.exited), + exitObserved: Boolean(options.exited), + exitCheckpointConfirmed: Boolean(options.exitCheckpointConfirmed), checkpointRetired: false, notified: Boolean(options.notified), inputRequired: Boolean(options.inputRequired), @@ -3113,7 +3124,7 @@ async function addSession(cwd, options = {}) { contextRevision: restoredContext.contextRevision, lastSummarizedRevision: restoredContext.lastSummarizedRevision, hasUserActivity: Boolean(options.hasUserActivity), - connecting: true, + connecting: !options.exited, activityCycleId: '', activityScanBuffer: '', lastWorkingAt: 0, @@ -3204,7 +3215,11 @@ async function addSession(cwd, options = {}) { const savedHostGeneration = session.hostGeneration; const restoredTerminalState = decodeTerminalState(options.terminalState); const restoredMobileTerminalState = decodeTerminalState(options.mobileTerminalState); - const details = await api.createSession({ + const restoredHistory = String(options.history || '').replace(/\r?\n/g, '\r\n'); + const restoredStoppedMobileState = restoredMobileTerminalState || (restoredHistory + ? `\x1bc\x1b[2m── restored stopped scrollback ──\x1b[0m\r\n${restoredHistory}` + : '\x1bc'); + let details = options.exited ? await api.restoreStoppedSession({ id, cwd, cols: terminal.cols, @@ -3212,43 +3227,69 @@ async function addSession(cwd, options = {}) { checkpointGeneration: session.hostGeneration, checkpointRevision: session.durableOutputRevision, terminalState: restoredTerminalState, - mobileTerminalState: restoredMobileTerminalState - }); - if (sessions.get(id) !== session) return session; - session.shell = details.shell; - session.cwd = details.cwd; - session.persistent = Boolean(details.persistent); - session.serverScrollback = Boolean(details.serverScrollback); - const nextHostGeneration = String(details.hostGeneration || session.hostGeneration || ''); - const canRestoreTerminalState = Boolean(restoredTerminalState - && savedHostGeneration - && nextHostGeneration === savedHostGeneration - && (details.reattached || details.exited)); - if (canRestoreTerminalState) { - await new Promise((resolve) => terminal.write(restoredTerminalState, resolve)); + mobileTerminalState: restoredStoppedMobileState, + terminalStateCols: restoredStateCols, + terminalStateRows: restoredStateRows, + exitCheckpointConfirmed: session.exitCheckpointConfirmed + }) : null; + if (details?.stopped) { + const stoppedOutput = restoredTerminalState || (restoredHistory + ? `\x1bc\x1b[2m── restored stopped scrollback ──\x1b[0m\r\n${restoredHistory}` + : '\x1bc'); + await new Promise((resolve) => terminal.write(stoppedOutput, resolve)); + terminal.options.disableStdin = true; + fit.fit(); + updateSessionItem(session); } else { - const restored = String(options.history || '').replace(/\r?\n/g, '\r\n'); - const safeHistory = restored - ? `\x1bc\x1b[2m── restored scrollback ──\x1b[0m\r\n${restored}\r\n\x1b[2m── new shell ──\x1b[0m\r\n` - : '\x1bc'; - if (options.terminalState || restored) { - await new Promise((resolve) => terminal.write(safeHistory, resolve)); + details ||= await api.createSession({ + id, + cwd, + cols: terminal.cols, + rows: terminal.rows, + checkpointGeneration: session.hostGeneration, + checkpointRevision: session.durableOutputRevision, + terminalState: restoredTerminalState, + mobileTerminalState: restoredMobileTerminalState + }); + if (sessions.get(id) !== session) return session; + session.exited = Boolean(details.exited); + session.exitObserved = Boolean(details.exited); + session.exitCheckpointConfirmed = false; + session.shell = details.shell; + session.cwd = details.cwd; + session.persistent = Boolean(details.persistent); + session.serverScrollback = Boolean(details.serverScrollback); + const nextHostGeneration = String(details.hostGeneration || session.hostGeneration || ''); + const canRestoreTerminalState = Boolean(restoredTerminalState + && savedHostGeneration + && nextHostGeneration === savedHostGeneration + && (details.reattached || details.exited)); + if (canRestoreTerminalState) { + await new Promise((resolve) => terminal.write(restoredTerminalState, resolve)); + } else { + const restored = String(options.history || '').replace(/\r?\n/g, '\r\n'); + const safeHistory = restored + ? `\x1bc\x1b[2m── restored scrollback ──\x1b[0m\r\n${restored}\r\n\x1b[2m── new shell ──\x1b[0m\r\n` + : '\x1bc'; + if (options.terminalState || restored) { + await new Promise((resolve) => terminal.write(safeHistory, resolve)); + } + session.durableOutputRevision = 0; + session.lastSerializedTerminalState = ''; + session.lastSerializedMobileTerminalState = ''; + session.lastSerializedHostGeneration = ''; + session.lastSerializedOutputRevision = 0; + } + session.hostGeneration = nextHostGeneration; + fit.fit(); + updateSessionItem(session); + await api.markRendererReady(id); + if (sessions.get(id) !== session) return session; + if (!details.exited) { + session.connecting = false; + terminal.options.disableStdin = false; + api.resize(id, terminal.cols, terminal.rows); } - session.durableOutputRevision = 0; - session.lastSerializedTerminalState = ''; - session.lastSerializedMobileTerminalState = ''; - session.lastSerializedHostGeneration = ''; - session.lastSerializedOutputRevision = 0; - } - session.hostGeneration = nextHostGeneration; - fit.fit(); - updateSessionItem(session); - await api.markRendererReady(id); - if (sessions.get(id) !== session) return session; - if (!details.exited) { - session.connecting = false; - terminal.options.disableStdin = false; - api.resize(id, terminal.cols, terminal.rows); } } catch (error) { if (sessions.get(id) !== session) return session; @@ -3271,7 +3312,11 @@ async function closeSession(id, { ensureSession = true } = {}) { const index = ids.indexOf(id); session.closing = true; try { - await api.close(id); + if (session.exited) { + await api.cleanupStopped(id, session.hostGeneration, {}, false); + } else { + await api.close(id); + } } catch (error) { session.closing = false; showToast(`Could not close the terminal: ${error.message}`); @@ -3537,13 +3582,17 @@ api.onRemoteInput(({ id, data }) => { if (session && !session.exited) trackTerminalInput(session, data); }); -api.onExit(({ id, exitCode, exitClaimToken, exitDeliveryToken }) => { +api.onExit(({ + id, exitCode, exitClaimToken, exitDeliveryToken, hostGeneration = '', outputRevision = 0 +}) => { const session = sessions.get(id); if (!session) { api.acknowledgeExit(id, exitClaimToken, exitDeliveryToken); return; } session.exited = true; + session.exitObserved = true; + session.exitCheckpointConfirmed = false; session.busy = false; setSessionInputRequired(session, false); reportSessionCompletion(session); @@ -3552,7 +3601,28 @@ api.onExit(({ id, exitCode, exitClaimToken, exitDeliveryToken }) => { window.clearTimeout(session.busyTimer); window.clearTimeout(session.responseSortTimer); session.terminal.options.disableStdin = true; - session.terminal.writeln(`\r\n\x1b[31m[Process exited with code ${exitCode}]\x1b[0m`); + if (hostGeneration) session.hostGeneration = String(hostGeneration); + if (Number.isSafeInteger(outputRevision) && outputRevision >= 0) { + session.durableOutputRevision = Math.max(session.durableOutputRevision, outputRevision); + } + session.terminal.write(`\r\n\x1b[31m[Process exited with code ${exitCode}]\x1b[0m\r\n`, () => { + const acknowledgeExit = () => api.acknowledgeExit(id, exitClaimToken, exitDeliveryToken); + if (exitClaimToken && hostGeneration) { + const acknowledge = async () => { + session.exitCheckpointConfirmed = true; + await persistWorkspaceNow({ + required: true, + protectedCheckpointSessionIds: new Set([id]) + }); + acknowledgeExit(); + }; + acknowledgeTerminalDataAfterCheckpoint(session, hostGeneration, outputRevision, acknowledge); + } else { + session.exitCheckpointConfirmed = true; + schedulePersist(); + acknowledgeExit(); + } + }); if (isSessionForeground(session)) { activeSubtitle.textContent = `${session.shell} · stopped · ${session.cwd}`; statusDot.classList.add('stopped'); @@ -3562,8 +3632,6 @@ api.onExit(({ id, exitCode, exitClaimToken, exitDeliveryToken }) => { updateSessionItem(session); if (getGroupForSession(session.id)?.sortBy === 'response') renderGroups(); else updateVisualState(); - schedulePersist(); - api.acknowledgeExit(id, exitClaimToken, exitDeliveryToken); }); new ResizeObserver((entries) => fitActiveForResize(entries[0])).observe(terminalStack); @@ -3910,6 +3978,8 @@ async function restoreSavedWorkspace() { title: saved.title, manualTitle: saved.manualTitle, shell: saved.shell, + exited: saved.exited, + exitCheckpointConfirmed: saved.exitCheckpointConfirmed, history: saved.history, terminalState: saved.terminalState, mobileTerminalState: saved.mobileTerminalState, diff --git a/src/workspace.js b/src/workspace.js index 14fbb0b..5a2ec36 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -200,19 +200,19 @@ export function serializeWorkspaceWithinBudget( return String(right.terminalState || '').length - String(left.terminalState || '').length; }); for (const session of leastImportantFirst) { - if (!session.history) continue; - session.history = ''; + if (!session.terminalState || protectedCheckpointSessionIds.has(session.id)) continue; + session.terminalState = ''; + if ('mobileTerminalState' in session) session.mobileTerminalState = ''; + session.hostGeneration = ''; + session.durableOutputRevision = 0; serialized = encode(); if (serializedByteLength(serialized) <= maximumBytes) { return { serialized, workspace: durableWorkspace }; } } for (const session of leastImportantFirst) { - if (!session.terminalState || protectedCheckpointSessionIds.has(session.id)) continue; - session.terminalState = ''; - if ('mobileTerminalState' in session) session.mobileTerminalState = ''; - session.hostGeneration = ''; - session.durableOutputRevision = 0; + if (!session.history) continue; + session.history = ''; serialized = encode(); if (serializedByteLength(serialized) <= maximumBytes) { return { serialized, workspace: durableWorkspace }; @@ -386,6 +386,8 @@ export function parseSavedWorkspace(raw) { manualTitle: Boolean(session.manualTitle), shell: typeof session.shell === 'string' ? session.shell : 'shell', cwd: typeof session.cwd === 'string' ? session.cwd : '', + exited: Boolean(session.exited), + exitCheckpointConfirmed: Boolean(session.exitCheckpointConfirmed), history: typeof session.history === 'string' ? session.history : '', terminalState, mobileTerminalState, diff --git a/test/mobile-sidebar-performance.test.cjs b/test/mobile-sidebar-performance.test.cjs index d24b3d1..737e842 100644 --- a/test/mobile-sidebar-performance.test.cjs +++ b/test/mobile-sidebar-performance.test.cjs @@ -61,7 +61,16 @@ test('server resync waits for WebSocket backpressure to drain', () => { test('exited mobile sessions retain a selectable final frame', () => { const main = fs.readFileSync(path.join(__dirname, '..', 'electron', 'main.cjs'), 'utf8'); + const renderer = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); assert.match(main, /const mobileExitedSessions = new Map\(\);/); assert.match(main, /retainMobileExitedSession\(id, session, finalScreen, exitCode\);[\s\S]*sessions\.delete\(id\);[\s\S]*broadcastMobileSnapshot\(\);/); assert.match(main, /message\.type === 'select' && \(session \|\| mobileExitedSessions\.has/); + assert.match(renderer, /mobileTerminalState: restoredStoppedMobileState,[\s\S]*?terminalStateCols: restoredStateCols,[\s\S]*?terminalStateRows: restoredStateRows/); + assert.match(main, /cleanupStoppedSession[\s\S]*?mobileExitedSessions\.set\(sessionId,[\s\S]*?terminalState[\s\S]*?MOBILE_RESTORED_STATE_MAX_CHARACTERS/); + assert.match(main, /const sessionIds = new Set\(\[\.\.\.sessions\.keys\(\), \.\.\.mobileExitedSessions\.keys\(\)\]\)/); + assert.doesNotMatch(main, /persistedExitedSessionIds/); + assert.match(main, /async function cleanupStoppedSession[\s\S]*?mobileExitedSessions\.set\(sessionId[\s\S]*?const liveSession = sessions\.get\(sessionId\)/); + assert.match(main, /client\.cleanupExitedSession\(sessionId, generation\)[\s\S]*?if \(!retainMobile\) mobileExitedSessions\.delete\(sessionId\)/); + assert.match(renderer, /if \(session\.exited\) \{\s*await api\.cleanupStopped\(id, session\.hostGeneration, \{\}, false\)/); + assert.match(main, /function sendMobileTerminalFrame[\s\S]*?const exited = mobileExitedSessions\.get\(id\)[\s\S]*?data: exited\?\.data/); }); diff --git a/test/session-reattach.test.cjs b/test/session-reattach.test.cjs index aab340d..c633d40 100644 --- a/test/session-reattach.test.cjs +++ b/test/session-reattach.test.cjs @@ -116,6 +116,32 @@ test('reattachment clamps invalid terminal dimensions', () => { assert.equal(session.rows, 30); }); +test('reattachment survives a direct PTY exiting during resize', () => { + let killCalls = 0; + const session = { + processHandle: { + pid: 8, + resize() { throw new Error('process already exited'); }, + kill() { killCalls += 1; } + }, + rendererReplay: 'final output', + cwd: '/workspace', + shell: 'bash', + tmux: null + }; + + let details; + assert.doesNotThrow(() => { + details = reattachSession('exiting-session', session, { cols: 90, rows: 25 }); + }); + assert.equal(session.rendererReplay, 'final output'); + assert.equal(killCalls, 0); + assert.deepEqual(details, { + id: 'exiting-session', pid: 8, cwd: '/workspace', shell: 'bash', resumed: false, + reattached: true, persistent: false, serverScrollback: false + }); +}); + test('session creation reattaches a live ID before spawning another shell', () => { const main = fs.readFileSync(path.join(__dirname, '..', 'electron', 'main.cjs'), 'utf8'); const start = main.indexOf('function createSession('); @@ -139,11 +165,12 @@ test('main buffers output per session until the renderer-ready handshake flushes assert.match(main, /if \(session\.rendererAttached && terminalRendererCanAcknowledge\(\)\) \{[\s\S]*?sendTerminalData\(\s*id, data, session\.rendererFlow, replayClaimToken, hostGeneration, outputRevision\s*\);[\s\S]*?\} else \{\s*bufferRendererOutput\(session, data\);/); assert.match(main, /ipcMain\.handle\('terminal:renderer-ready'[\s\S]*?markTerminalRendererReady/); assert.match(preload, /markRendererReady: \(id\) => ipcRenderer\.invoke\('terminal:renderer-ready', id\)/); - assert.match(renderer, /const details = await api\.createSession[\s\S]*?await api\.markRendererReady\(id\);/); + assert.match(renderer, /details \|\|= await api\.createSession[\s\S]*?await api\.markRendererReady\(id\);/); assert.match(main, /rendererReplayInFlight = \{[\s\S]*?claimToken: replayClaimToken,[\s\S]*?deliveryToken: replayDeliveryToken/); assert.match(main, /terminal:data-ack'[\s\S]*?rendererReplayInFlight\?\.claimToken === String\(replayClaimToken[\s\S]*?rendererReplayInFlight\.deliveryToken === String\(replayDeliveryToken[\s\S]*?processHandle\.acknowledgeReplay/); assert.match(renderer, /terminal\.write\(data, \(\) => \{[\s\S]*?api\.acknowledgeData\([\s\S]*?replayClaimToken, replayDeliveryToken, rendererDataDeliveryToken,[\s\S]*?exitClaimToken, exitDeliveryToken/); assert.match(main, /function deliverDesktopExitedSession[\s\S]*?exitClaimToken: exited\.exitClaimToken[\s\S]*?exitDeliveryToken/); + assert.match(main, /exitDeliveryToken: exited\.exitDeliveryToken,\s*hostGeneration: exited\.hostGeneration,\s*outputRevision: exited\.outputRevision/); assert.match(main, /terminal:exit-ack'[\s\S]*?acknowledgeDesktopExitedSession/); assert.match(renderer, /api\.acknowledgeExit\(id, exitClaimToken, exitDeliveryToken\);/); assert.match(main, /function requeueRendererOutput[\s\S]*?rendererOutputInFlight[\s\S]*?unacknowledged[\s\S]*?rendererReplay/); @@ -165,14 +192,15 @@ test('session creation shares pending work and a close cancels before registrati assert.match(main, /const pendingSession = pendingSessionCreations\.get\(id\);\s*if \(pendingSession\) return pendingSession\.promise;/); assert.match(main, /if \(pendingCreation\.cancelled\) \{[\s\S]*?await processHandle\.kill\(\);[\s\S]*?closed before creation completed/); assert.match(main, /const pendingCreation = pendingSessionCreations\.get\(id\);\s*if \(pendingCreation\) \{\s*pendingCreation\.cancelled = true;[\s\S]*?await pendingCreation\.promise/); - assert.match(renderer, /const details = await api\.createSession[\s\S]*?if \(sessions\.get\(id\) !== session\) return session;[\s\S]*?await api\.markRendererReady\(id\);\s*if \(sessions\.get\(id\) !== session\) return session;/); + assert.match(renderer, /details \|\|= await api\.createSession[\s\S]*?if \(sessions\.get\(id\) !== session\) return session;[\s\S]*?await api\.markRendererReady\(id\);\s*if \(sessions\.get\(id\) !== session\) return session;/); }); test('renderer durably saves a new session id before asking the PTY host to spawn it', () => { const renderer = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); const main = fs.readFileSync(path.join(__dirname, '..', 'electron', 'main.cjs'), 'utf8'); + const preload = fs.readFileSync(path.join(__dirname, '..', 'electron', 'preload.cjs'), 'utf8'); const persist = renderer.indexOf('await persistWorkspaceNow({ required: true });', renderer.indexOf('async function addSession(')); - const spawn = renderer.indexOf('const details = await api.createSession', persist); + const spawn = renderer.indexOf('details ||= await api.createSession', persist); assert.ok(persist >= 0); assert.ok(spawn > persist); @@ -183,18 +211,27 @@ test('renderer durably saves a new session id before asking the PTY host to spaw assert.match(renderer, /const enqueueWorkspacePersistence = createSerializedAsyncQueue\(\);[\s\S]*?function persistWorkspaceNow\(options = \{\}\)[\s\S]*?enqueueWorkspacePersistence\(\(\) => persistWorkspaceSnapshot\(options\)\)/); assert.match(renderer, /function acknowledgeTerminalDataAfterCheckpoint\(session, hostGeneration, outputRevision, acknowledge\) \{\s*if \(session\.checkpointRetired \|\| sessions\.get\(session\.id\) !== session\) return;\s*pendingTerminalCheckpointAcknowledgements\.push\(\{[\s\S]*?id: session\.id, session,[\s\S]*?hostGeneration: String\(hostGeneration \|\| ''\), outputRevision, acknowledge/); assert.match(renderer, /const acknowledgementGroups = groupTerminalCheckpointAcknowledgements\([\s\S]*?activeTerminalCheckpointAcknowledgements\(acknowledgements, sessions\)[\s\S]*?for \(const \{ id, acknowledgements: sessionAcknowledgements \} of acknowledgementGroups\)[\s\S]*?protectedCheckpointSessionIds: new Set\(\[id\]\)[\s\S]*?activeTerminalCheckpointAcknowledgements\(activeAcknowledgements, sessions\)/); + assert.match(renderer, /if \(persistedCheckpointCoversDelivery\(session, entry\)\) \{\s*try \{\s*await entry\.acknowledge\(\);\s*\} catch \{\s*unsatisfied\.push\(entry\);/); assert.match(renderer, /finally \{\s*restoringWorkspace = false;\s*resolveWorkspaceRestore\(\);\s*\}[\s\S]*?if \(pendingTerminalCheckpointAcknowledgements\.length > 0\) \{\s*await flushTerminalCheckpointAcknowledgements\(\{ forceSave: true \}\);/); assert.match(renderer, /cursorBlink: false,\s*disableStdin: true,/); assert.match(renderer, /const serializeAddon = new SerializeAddon\(\);[\s\S]*?terminal\.loadAddon\(serializeAddon\)/); assert.match(renderer, /const terminalCheckpoint = serializedTerminalCheckpoint\(session\);[\s\S]*?terminalState: terminalCheckpoint\.state,\s*mobileTerminalState: terminalCheckpoint\.mobileState,\s*terminalStateCols: session\.terminal\.cols,\s*terminalStateRows: session\.terminal\.rows,\s*hostGeneration: terminalCheckpoint\.hostGeneration,\s*durableOutputRevision: terminalCheckpoint\.outputRevision/); + assert.match(renderer, /cwd: session\.cwd,\s*exited: Boolean\(session\.exitObserved\),\s*exitCheckpointConfirmed: Boolean\(session\.exitCheckpointConfirmed\),\s*history: terminalHistory\(session\.terminal\)/); assert.match(renderer, /const checkpointSidecars = new Map\(\);[\s\S]*?for \(const id of protectedCheckpointSessionIds\)[\s\S]*?await api\.saveTerminalCheckpoint\(checkpoint\);[\s\S]*?serializeWorkspaceWithinBudget\(workspaceRecord\)/); assert.match(renderer, /serializeAddon\.serialize\(\{ scrollback: LIVE_TERMINAL_SCROLLBACK_LINES \}\)/); assert.match(renderer, /for \(const id of restoreOrder\)[\s\S]*?const saved = applyTerminalCheckpointBackups\(\s*\{ sessions: \[savedDescriptor\] \}, api\.getTerminalCheckpointSync\(id\)\s*\)[\s\S]*?await addSession/); - assert.match(renderer, /const restoredTerminalState = decodeTerminalState\(options\.terminalState\);[\s\S]*?const details = await api\.createSession[\s\S]*?const canRestoreTerminalState = Boolean\(restoredTerminalState[\s\S]*?nextHostGeneration === savedHostGeneration[\s\S]*?details\.reattached \|\| details\.exited[\s\S]*?terminal\.write\(restoredTerminalState, resolve\)[\s\S]*?safeHistory/); + assert.match(renderer, /exited: saved\.exited,\s*exitCheckpointConfirmed: saved\.exitCheckpointConfirmed,/); + assert.match(renderer, /const restoredTerminalState = decodeTerminalState\(options\.terminalState\);[\s\S]*?details \|\|= await api\.createSession[\s\S]*?const canRestoreTerminalState = Boolean\(restoredTerminalState[\s\S]*?nextHostGeneration === savedHostGeneration[\s\S]*?details\.reattached \|\| details\.exited[\s\S]*?terminal\.write\(restoredTerminalState, resolve\)[\s\S]*?safeHistory/); + assert.match(renderer, /let details = options\.exited \? await api\.restoreStoppedSession\(\{[\s\S]*?terminalStateCols: restoredStateCols,[\s\S]*?exitCheckpointConfirmed: session\.exitCheckpointConfirmed[\s\S]*?if \(details\?\.stopped\) \{[\s\S]*?terminal\.write\(stoppedOutput, resolve\)/); + assert.match(preload, /restoreStoppedSession: \(options\) => ipcRenderer\.invoke\('terminal:restore-stopped'/); + assert.match(preload, /cleanupStopped: \(id, hostGeneration, snapshot = \{\}, retainMobile = true\) => ipcRenderer\.invoke\('terminal:cleanup-stopped'/); + assert.match(main, /async function restoreStoppedSession[\s\S]*?tmuxSessionExists\(tmux, tmuxSession\)[\s\S]*?return createSession\(options\)[\s\S]*?process\.platform === 'win32' && !confirmedCheckpoint[\s\S]*?return createSession\(options\)[\s\S]*?cleanupStoppedSession/); + assert.match(main, /async function cleanupStoppedSession\(id, hostGeneration, snapshot = \{\}, \{ retainMobile = true \} = \{\}\)[\s\S]*?mobileExitedSessions\.set\(sessionId[\s\S]*?liveSession\.processHandle\?\.generation[\s\S]*?await exitedSession\.processHandle\.kill\(\)[\s\S]*?client\.cleanupExitedSession\(sessionId, generation\)/); assert.match(renderer, /checkpointGeneration: session\.hostGeneration,\s*checkpointRevision: session\.durableOutputRevision/); assert.match(renderer, /checkpointRevision: session\.durableOutputRevision,\s*terminalState: restoredTerminalState/); assert.match(renderer, /async function closeSession\(id, \{ ensureSession = true \} = \{\}\)[\s\S]*?await api\.close\(id\)[\s\S]*?sessions\.delete\(id\)/); - assert.match(renderer, /session\.closing = true;\s*try \{\s*await api\.close\(id\);[\s\S]*?session\.checkpointRetired = true;[\s\S]*?pendingTerminalCheckpointAcknowledgements\.splice/); + assert.match(renderer, /session\.closing = true;\s*try \{[\s\S]*?await api\.close\(id\);[\s\S]*?session\.checkpointRetired = true;[\s\S]*?pendingTerminalCheckpointAcknowledgements\.splice/); + assert.match(renderer, /session\.closing = true;\s*try \{\s*if \(session\.exited\) \{\s*await api\.cleanupStopped\(id, session\.hostGeneration, \{\}, false\);\s*\} else \{\s*await api\.close\(id\);/); assert.match(main, /await session\.processHandle\.kill\(\);\s*\} catch \(error\) \{\s*if \(session\.windowsHosted\) throw error;/); assert.match(main, /if \(exitedSession\?\.exitClaimToken[\s\S]*?await exitedSession\.processHandle\.kill\(\);[\s\S]*?desktopExitedSessions\.delete\(id\)/); assert.match(renderer, /function serializedTerminalCheckpoint\(session\) \{\s*if \(!session\.hostGeneration\)[\s\S]*?state: '', mobileState: ''/); @@ -202,6 +239,13 @@ test('renderer durably saves a new session id before asking the PTY host to spaw assert.match(main, /terminalSessionDrainActive = true;[\s\S]*?await Promise\.allSettled\(\[\.\.\.pendingTerminalCloseOperations\]\)[\s\S]*?detachAllSessionsPromise = null/); assert.match(main, /ipcMain\.handle\('terminal:close'[\s\S]*?if \(terminalSessionDrainActive\) throw new Error/); assert.match(renderer, /session\.terminal\.write\(data, \(\) => \{[\s\S]*?session\.durableOutputRevision = Math\.max\(session\.durableOutputRevision, outputRevision\);[\s\S]*?acknowledgeTerminalDataAfterCheckpoint\(session, hostGeneration, outputRevision, acknowledge\)/); + assert.match(renderer, /api\.onExit\(\(\{[\s\S]*?session\.exitObserved = true;[\s\S]*?session\.exitCheckpointConfirmed = false;[\s\S]*?const acknowledge = async \(\) => \{[\s\S]*?session\.exitCheckpointConfirmed = true;[\s\S]*?await persistWorkspaceNow\(\{[\s\S]*?protectedCheckpointSessionIds: new Set\(\[id\]\)[\s\S]*?acknowledgeExit\(\);[\s\S]*?acknowledgeTerminalDataAfterCheckpoint\(session, hostGeneration, outputRevision, acknowledge\)/); + assert.match(renderer, /catch \(error\) \{\s*if \(sessions\.get\(id\) !== session\) return session;\s*session\.exited = true;[\s\S]*?Could not start the shell/); + const startupFailure = renderer.slice( + renderer.indexOf('} catch (error) {', renderer.indexOf('async function addSession(')), + renderer.indexOf('if (options.activate !== false)', renderer.indexOf('async function addSession(')) + ); + assert.doesNotMatch(startupFailure, /exitObserved/); assert.match(renderer, /terminal\.onData\(\(data\) => \{\s*if \(session\.exited \|\| session\.connecting\) return;/); assert.match(renderer, /await api\.markRendererReady\(id\);[\s\S]*?if \(!details\.exited\) \{\s*session\.connecting = false;\s*terminal\.options\.disableStdin = false;\s*api\.resize\(id, terminal\.cols, terminal\.rows\);/); }); diff --git a/test/windows-pty-persistence.test.cjs b/test/windows-pty-persistence.test.cjs index 0ea6c08..d0c0e6f 100644 --- a/test/windows-pty-persistence.test.cjs +++ b/test/windows-pty-persistence.test.cjs @@ -237,6 +237,21 @@ test('a rejected hosted PTY kill preserves the handle and can be retried', async assert.equal(client.handles.has(handle.id), false); }); +test('generation-scoped stopped cleanup uses an awaited host request', async () => { + const socket = fakeSocket(); + const client = new WindowsPtyHostClient(socket); + const cleanup = client.cleanupExitedSession('stopped-session', 'stopped-generation'); + const request = socket.writes[0]; + + assert.equal(request.action, 'kill'); + assert.equal(request.id, 'stopped-session'); + assert.equal(request.generation, 'stopped-generation'); + socket.emit('data', `${JSON.stringify({ + type: 'response', requestId: request.requestId, result: { killed: false } + })}\n`); + assert.deepEqual(await cleanup, { killed: false }); +}); + test('exit events received before handle registration are delivered after creation', async () => { const socket = fakeSocket(); const client = new WindowsPtyHostClient(socket); @@ -586,6 +601,56 @@ test('an authenticated reconnect cancels the host idle-exit timer', { assert.equal(JSON.parse(fs.readFileSync(metadataPath, 'utf8')).pid, hostPid); }); +test('fresh clients clean only the exact stopped host generation', { + timeout: 20_000, + skip: process.platform !== 'win32' +}, async (t) => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'sideterm-stopped-cleanup-test-')); + const metadataPath = path.join(temporaryDirectory, 'host.json'); + const hostScript = path.join(__dirname, '..', 'electron', 'sessions', 'windows-pty-host.cjs'); + const id = 'stopped-cleanup-session'; + let generation = ''; + let client = null; + t.after(async () => { + try { + if (client && generation) await client.cleanupExitedSession(id, generation); + await client?.shutdownIfIdle(); + } catch { + // Best-effort cleanup for a failed integration assertion. + } + client?.disconnect(); + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + client = await connectWindowsPtyHost({ metadataPath, hostScript, executablePath: process.execPath }); + const handle = await client.createSession({ + id, + executable: process.execPath, + args: ['-e', 'setTimeout(() => process.exit(0), 100)'], + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: temporaryDirectory, + env: {} + }); + generation = handle.generation; + await new Promise((resolve) => handle.onExit(resolve)); + client.disconnect(); + client = null; + await new Promise((resolve) => setTimeout(resolve, 100)); + + client = await connectWindowsPtyHost({ metadataPath, hostScript, executablePath: process.execPath }); + assert.deepEqual(await client.shutdownIfIdle(), { idle: false }); + await assert.rejects( + client.cleanupExitedSession(id, `${generation}-different`), + /generation changed/ + ); + assert.deepEqual(await client.shutdownIfIdle(), { idle: false }); + assert.deepEqual(await client.cleanupExitedSession(id, generation), { killed: false }); + assert.deepEqual(await client.shutdownIfIdle(), { idle: true }); + generation = ''; +}); + test('detached PTY host preserves a live session across client restarts', { timeout: 30_000, skip: process.platform !== 'win32' diff --git a/test/workspace.test.js b/test/workspace.test.js index 28a211b..ee6a708 100644 --- a/test/workspace.test.js +++ b/test/workspace.test.js @@ -199,6 +199,35 @@ test('workspace budgeting drops generationless snapshots before usable history', assert.equal(result.workspace.sessions[0].history, 'usable history'); }); +test('workspace budgeting drops generation-bound snapshots before fallback history', () => { + const workspace = { + version: WORKSPACE_VERSION, + savedAt: 1, + groups: [{ id: 'group', sessionIds: ['windows'] }], + sessions: [{ + id: 'windows', groupId: 'group', history: 'fallback history', + terminalState: 'x'.repeat(2_000), mobileTerminalState: 'mobile', + hostGeneration: 'windows-host', durableOutputRevision: 12, + exitCheckpointConfirmed: true, links: [] + }], + activeId: 'windows', + activeGroupId: 'group' + }; + const withoutSnapshot = structuredClone(workspace); + Object.assign(withoutSnapshot.sessions[0], { + terminalState: '', mobileTerminalState: '', hostGeneration: '', durableOutputRevision: 0 + }); + const maximumBytes = new TextEncoder().encode(JSON.stringify(withoutSnapshot)).byteLength; + + const result = serializeWorkspaceWithinBudget(workspace, maximumBytes); + + assert.equal(result.workspace.sessions[0].terminalState, ''); + assert.equal(result.workspace.sessions[0].hostGeneration, ''); + assert.equal(result.workspace.sessions[0].durableOutputRevision, 0); + assert.equal(result.workspace.sessions[0].exitCheckpointConfirmed, true); + assert.equal(result.workspace.sessions[0].history, 'fallback history'); +}); + function fixture() { const first = createGroup('first', 'First'); const second = createGroup('second', 'Second'); @@ -278,7 +307,7 @@ test('saved workspaces validate, deduplicate, and restore unassigned sessions', { id: 'second', title: '', color: 'not-a-color', collapsed: true, sessionIds: [] } ], sessions: [ - { id: 'a', groupId: 'first', title: 'One', manualTitle: true, cwd: '/tmp', history: 'hello', terminalState: '\u001b[?1049hfull screen', terminalStateCols: 132, terminalStateRows: 41, hostGeneration: 'generation-a', durableOutputRevision: 17, notified: true, inputRequired: true, attentionCycleId: 'cycle-a', activityArmed: true, displayName: 'API work', summary: 'Fix auth', agent: 'Codex', aiInitialSummaryDone: true, lastAiSummaryAt: 1234, lastAiContextActivityAt: 1220, staleAiSummaryDone: true, createdAt: 10, lastResponseAt: 20, links: [{ url: 'https://example.com/docs', seenAt: 0 }, { url: 'https://github.com/a/b/pull/1/files', seenAt: 1 }] }, + { id: 'a', groupId: 'first', title: 'One', manualTitle: true, cwd: '/tmp', exited: true, exitCheckpointConfirmed: true, history: 'hello', terminalState: '\u001b[?1049hfull screen', terminalStateCols: 132, terminalStateRows: 41, hostGeneration: 'generation-a', durableOutputRevision: 17, notified: true, inputRequired: true, attentionCycleId: 'cycle-a', activityArmed: true, displayName: 'API work', summary: 'Fix auth', agent: 'Codex', aiInitialSummaryDone: true, lastAiSummaryAt: 1234, lastAiContextActivityAt: 1220, staleAiSummaryDone: true, createdAt: 10, lastResponseAt: 20, links: [{ url: 'https://example.com/docs', seenAt: 0 }, { url: 'https://github.com/a/b/pull/1/files', seenAt: 1 }] }, { id: 'b', groupId: 'second', title: 'Two' } ] })); @@ -294,6 +323,8 @@ test('saved workspaces validate, deduplicate, and restore unassigned sessions', assert.equal(saved.activeGroupId, 'first'); assert.equal(saved.sessions[0].displayName, 'API work'); assert.equal(saved.sessions[0].manualTitle, true); + assert.equal(saved.sessions[0].exited, true); + assert.equal(saved.sessions[0].exitCheckpointConfirmed, true); assert.equal(saved.sessions[0].attentionCycleId, 'cycle-a'); assert.equal(saved.sessions[0].inputRequired, true); assert.equal(saved.sessions[0].activityArmed, true); @@ -375,7 +406,7 @@ test('per-session checkpoint sidecars overlay workspace sessions without replaci version: WORKSPACE_VERSION, groups: [], sessions: [ - { id: 'dropped-inline', hostGeneration: '', durableOutputRevision: 0, terminalState: '' }, + { id: 'dropped-inline', exited: true, exitCheckpointConfirmed: true, hostGeneration: '', durableOutputRevision: 0, terminalState: '' }, { id: 'new-generation', hostGeneration: 'new', durableOutputRevision: 1, terminalState: '\u001bcnew' } ] }; @@ -388,6 +419,7 @@ test('per-session checkpoint sidecars overlay workspace sessions without replaci assert.equal(restored.sessions[0].mobileTerminalState, '\u001bcmobile'); assert.equal(restored.sessions[0].hostGeneration, 'host-a'); assert.equal(restored.sessions[0].durableOutputRevision, 8); + assert.equal(restored.sessions[0].exitCheckpointConfirmed, true); assert.equal(restored.sessions[1].terminalState, '\u001bcnew'); assert.equal(restored.sessions[1].hostGeneration, 'new'); });