diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index ba6da1b8..6d634b25 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -558,6 +558,44 @@ response-ready (unchanged)" pin both branches of the decision — the icon slot's `session-icon--waiting`/`session-icon--response-ready` class and title, side by side, so a future reader finds the split intentional. +#### A session main drops (issue #375) + +Main dropping a local-pty session (`process-exited`, then `activeSessions.delete`) +is the one moment the renderer's running state for it has to end — in the +sidebar row, the status bar and the activity state. The `pty-gone` purge in +`updateRunningIndicators()` only runs when the running-set *signature* changes, +so it could not be the only owner: a `cli-busy-state` or notification that lands +after a poll already removed the id (a CLI stuck in an API retry loop keeps +rewriting its title while it is killed) re-armed `cli-busy` on a row the +signature gate then never revisited, and the status bar's `N running` was only +redrawn by `loadProjects()`. + +- `onProcessExited` (`app.js`) removes the id from `activePtyIds`, calls + `dropLocalPtySession(id, 'process-exited')` for a non-remote row, and runs + `updateRunningIndicators()` at once instead of waiting for the next poll. +- `dropLocalPtySession` (`session-activity.js`) is the single drop: live + subagents first (`clearActiveSubagentsFor`), then `purgeActivityFor`. The + `pty-gone` scan uses the same helper. +- `updateRunningIndicators()` calls `renderDefaultStatus()` whenever the set + changed, so the status bar count follows `activePtyIds`. + +**Pty generation.** `wireSessionPty` (`main.js`) gives every spawn the next value of +a monotonically increasing counter (`session.generation`). It is returned by all +three `open-terminal` replies and sent as the fifth argument of every +`process-exited` (the fork re-key's second send included). `app.js` records the +generation of the last reply per id (`ptyGenerations`) and `handleProcessExited` +ignores an exit older than it — no drop, no `closed`, no banner. An exit that +lands while `openSession` awaits the reply is buffered (`pendingOpens`); +`settlePtyOpen` then applies it unless the reply's generation is newer, so a +stale exit never touches the new pty and a fast-failing launch still reads +as exited. Chosen over suppressing the event in main because main cannot know +which exit the renderer has already seen when it is delivered. + +Remote rows stay with the remote adapter (see "Row ownership"). Not covered: a +busy signal that arrives after `process-exited` — main sends none, the PTY is +gone. The pre-fix sequence is reasoned from the code, not reproduced live; +`test/dropped-session-state.test.js` pins each step. + ### The local-transcript adapter (step 4) `public/local-transcript-adapter.js` keeps one persistent diff --git a/CHANGELOG.md b/CHANGELOG.md index 068bd1cc..03cca43b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed +- A session that has exited no longer keeps a busy dot in the sidebar, and the status bar's running count drops as soon as the session ends instead of waiting for the next refresh. (#375) - Quitting, closing the window or reloading while a file in the file panel has unsaved edits now asks first, in any session, kept-aside tabs included: Save writes them (a file that changed on disk is not overwritten), Discard drops them, Cancel stays. If Switchboard does not answer within a few seconds, it closes anyway. (#373) - The IDE Emulation label in a session's terminal header now says whether the CLI is connected: it reads "IDE Emulation" only while it is, "IDE Emulation: waiting for CLI" when Switchboard is listening but the CLI has not connected, and "IDE Emulation: failed" when it could not start for that session, with the reason in its tooltip. A session whose IDE Emulation port was already taken no longer shows the label as if it worked. (#320) - On Windows, the file panel no longer opens or saves a credential file (such as one under `.ssh`) through its 8.3 short name or a `\\?\` path. (#390) diff --git a/eslint.config.js b/eslint.config.js index 6f58a747..c28248b4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -154,6 +154,7 @@ const rendererCrossFileGlobals = { currentActivitySeq: 'readonly', forgetActivitySeq: 'readonly', purgeActivityFor: 'readonly', + dropLocalPtySession: 'readonly', pruneRemoteActivityTimers: 'readonly', // public/session-activity.js's persisted per-session state — see .ai/contexts/session-state.md ("The local-pty adapter") localPtyState: 'readonly', diff --git a/main.js b/main.js index 06ced8d3..751c69e2 100644 --- a/main.js +++ b/main.js @@ -2198,7 +2198,11 @@ function sandboxBindEnv(dirs) { } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") +let ptyGenerationCounter = 0; + +// see .ai/contexts/session-state.md ("A session main drops") function wireSessionPty(session, sessionId, ptyProcess) { + session.generation = ++ptyGenerationCounter; ptyProcess.onData(data => { const currentId = session.realSessionId || sessionId; @@ -2301,12 +2305,12 @@ function wireSessionPty(session, sessionId, ptyProcess) { const realId = session.realSessionId || sessionId; if (TRACE.on) trace('pty.exit', realId, { exitCode, signal: exitSignal, stopped, alsoUnder: realId !== sessionId ? sessionId : null, wasBusy: !!session._cliBusy, sent: !!(mainWindow && !mainWindow.isDestroyed()) }); if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('process-exited', realId, exitCode, exitSignal, stopped); + mainWindow.webContents.send('process-exited', realId, exitCode, exitSignal, stopped, session.generation); // If a fork transition re-keyed this session under realId but the PTY // exited before transition detection ran, also notify the renderer for // the original sessionId so it doesn't stay stuck as "Running". if (realId !== sessionId && activeSessions.has(sessionId)) { - mainWindow.webContents.send('process-exited', sessionId, exitCode, exitSignal, stopped); + mainWindow.webContents.send('process-exited', sessionId, exitCode, exitSignal, stopped, session.generation); } } activeSessions.delete(realId); @@ -2347,6 +2351,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se ok: true, reattached: true, sandbox: !!session.sandbox, mcpState: session.mcpError ? 'failed' : getMcpState(session.realSessionId || sessionId), mcpError: session.mcpError || null, + generation: session.generation, }; } @@ -2381,7 +2386,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se }; activeSessions.set(sessionId, remoteSession); wireSessionPty(remoteSession, sessionId, attachResult.ptyProcess); - return { ok: true, reattached: false, remote: true, sandbox: false }; + return { ok: true, reattached: false, remote: true, sandbox: false, generation: remoteSession.generation }; } } @@ -2690,6 +2695,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se ok: true, reattached: false, sandbox: !!sessionOptions?.sandbox, mcpState: mcpError ? 'failed' : getMcpState(sessionId), mcpError, + generation: session.generation, }; }); diff --git a/preload.js b/preload.js index f6f473f2..50dbf2d1 100644 --- a/preload.js +++ b/preload.js @@ -111,7 +111,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('session-detected', (_event, tempId, realId) => callback(tempId, realId)); }, onProcessExited: (callback) => { - ipcRenderer.on('process-exited', (_event, sessionId, exitCode, signal, stopped) => callback(sessionId, exitCode, signal, stopped)); + ipcRenderer.on('process-exited', (_event, sessionId, exitCode, signal, stopped, generation) => callback(sessionId, exitCode, signal, stopped, generation)); }, onTerminalNotification: (callback) => { ipcRenderer.on('terminal-notification', (_event, sessionId, message) => callback(sessionId, message)); diff --git a/public/app.js b/public/app.js index b5f42813..54c151db 100644 --- a/public/app.js +++ b/public/app.js @@ -125,6 +125,8 @@ let showTodayOnly = false; let cachedProjects = []; let cachedAllProjects = []; let activePtyIds = new Set(); +const ptyGenerations = new Map(); +const pendingOpens = new Map(); let sortedOrder = []; // [{ projectPath, itemIds: [itemId, ...] }, ...] — single source of truth for sidebar order let activeTab = 'sessions'; let visibleSessionCount = SETTING_DEFAULTS.visibleSessionCount; @@ -498,7 +500,7 @@ window.api.onSessionForked((oldId, newId) => { pollActiveSessions(); }); -window.api.onProcessExited((sessionId, exitCode, signal, stopped) => { +function applyProcessExit(sessionId, exitCode, signal, stopped) { if (window.ATRACE) window.atrace('recv.process-exited', sessionId, { exitCode, signal, stopped }); const entry = openSessions.get(sessionId); const session = sessionMap.get(sessionId); @@ -509,6 +511,10 @@ window.api.onProcessExited((sessionId, exitCode, signal, stopped) => { return; } noteSessionExit(sessionId, exitCode, signal, stopped); + // see .ai/contexts/session-state.md ("A session main drops") + activePtyIds.delete(sessionId); + if (!sessionItemEl(sessionId)?.dataset.remoteAlias) dropLocalPtySession(sessionId, 'process-exited'); + updateRunningIndicators(); const exit = lastSessionExit(sessionId); if (entry) { entry.closed = true; @@ -562,6 +568,37 @@ window.api.onProcessExited((sessionId, exitCode, signal, stopped) => { // Claude session exited → update persisted working set (entry.closed=true → excluded from set) schedulePersistWorkingSet(); pollActiveSessions(); +} + +// see .ai/contexts/session-state.md ("A session main drops") +function handleProcessExited(sessionId, exitCode, signal, stopped, generation) { + const recorded = ptyGenerations.get(sessionId); + if (typeof generation === 'number' && typeof recorded === 'number' && generation < recorded) return; + const pending = pendingOpens.get(sessionId); + if (pending) { + pending.push({ args: [sessionId, exitCode, signal, stopped], generation }); + return; + } + applyProcessExit(sessionId, exitCode, signal, stopped); +} + +function beginPtyOpen(sessionId) { + pendingOpens.set(sessionId, []); +} + +function settlePtyOpen(sessionId, result) { + const buffered = pendingOpens.get(sessionId) || []; + pendingOpens.delete(sessionId); + const generation = result && result.generation; + if (typeof generation === 'number') ptyGenerations.set(sessionId, generation); + for (const exit of buffered) { + if (typeof generation === 'number' && typeof exit.generation === 'number' && exit.generation < generation) continue; + applyProcessExit(...exit.args); + } +} + +window.api.onProcessExited((sessionId, exitCode, signal, stopped, generation) => { + handleProcessExited(sessionId, exitCode, signal, stopped, generation); }); // --- Terminal notifications (iTerm2 OSC 9 — "needs attention") --- @@ -956,9 +993,7 @@ function updateRunningIndicators() { // A stopped PTY can never emit subagent-completed (stop-session kills // the process; detectSubagentTransitions skips exited sessions), so // drop the live-subagent state now instead of waiting for the TTL. - clearActiveSubagentsFor(id); - // Runs after clearActiveSubagentsFor — see .ai/contexts/session-state.md ("The local-pty adapter") - purgeActivityFor(id, 'pty-gone'); + dropLocalPtySession(id, 'pty-gone'); } if (item.dataset.remoteAlias) setRemoteAttached(id, running); // local-pty takes over a row the user just opened — see .ai/contexts/session-state.md @@ -976,6 +1011,7 @@ function updateRunningIndicators() { const dot = group.querySelector('.slug-group-dot'); if (dot) dot.classList.toggle('running', hasRunning); }); + renderDefaultStatus(); } // Update grid card dots and status text — always run because sessionBusyState @@ -1226,7 +1262,15 @@ async function openSession(session, customOptions, { automatic = false, live } = const resumeOptions = customOptions || (session.type === 'terminal' ? { type: 'terminal' } : await resolveDefaultSessionOptions({ projectPath })); forgetSessionExit(sessionId); - const result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize); + beginPtyOpen(sessionId); + let result; + try { + result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize); + } catch (err) { + settlePtyOpen(sessionId, null); + throw err; + } + settlePtyOpen(sessionId, result); if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; diff --git a/public/session-activity.js b/public/session-activity.js index 7bfd004a..25829e9c 100644 --- a/public/session-activity.js +++ b/public/session-activity.js @@ -97,6 +97,12 @@ function purgeActivityFor(sessionId, via) { applyStateClasses(sessionId, createSessionState('local-pty').snapshot()); } +// see .ai/contexts/session-state.md ("A session main drops") +function dropLocalPtySession(sessionId, via) { + clearActiveSubagentsFor(sessionId); + purgeActivityFor(sessionId, via); +} + // Central activity dispatcher. `via` is trace-only — see docs/activity-trace.md. // opts.armReady=false: going idle must not arm response-ready — see .ai/contexts/session-cache.md ("Remote hosts — busy spinner") function setActivity(sessionId, active, via, opts) { diff --git a/test/dropped-session-state.test.js b/test/dropped-session-state.test.js new file mode 100644 index 00000000..fbe89c2c --- /dev/null +++ b/test/dropped-session-state.test.js @@ -0,0 +1,166 @@ +// A session main no longer holds must stop counting as running — in the +// sidebar row, the status bar and the activity state. see .ai/contexts/session-state.md ("A session main drops") +// +// app.js cannot be evaluated whole in jsdom, so the shipped onProcessExited +// handler and updateRunningIndicators are cut out of its source and run +// against stubs (same technique as process-exit-status.test.js). +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { setupSidebarDom } = require('./dom-setup'); + +const ROOT = path.join(__dirname, '..'); +const APP_SRC = fs.readFileSync(path.join(ROOT, 'public', 'app.js'), 'utf8'); + +function sliceBlock(marker, tail = '') { + const start = APP_SRC.indexOf(marker); + assert.notEqual(start, -1, `app.js must contain ${marker}`); + const lineEndingBrace = /\{\r?\n/g; + lineEndingBrace.lastIndex = start; + const open = lineEndingBrace.exec(APP_SRC); + let depth = 0; + for (let i = open.index; i < APP_SRC.length; i++) { + if (APP_SRC[i] === '{') depth++; + else if (APP_SRC[i] === '}' && --depth === 0) return APP_SRC.slice(start, i + 1) + tail; + } + throw new Error(`unbalanced block after ${marker}`); +} + +const PRELUDE = ` + let activePtyIds = new Set(); + let _lastPtySignature = ''; + const ptyGenerations = new Map(); + const pendingOpens = new Map(); + const calls = { dropped: [], status: 0, indicators: 0 }; + const openSessions = new Map(); + const sessionMap = new Map(); + const pendingSessions = new Map(); + const cachedProjects = []; + const cachedAllProjects = []; + const gridCards = new Map(); + const sessionBusyState = new Map(); + let gridViewActive = false; + let activeSessionId = null; + const gridViewerCount = document.createElement('span'); + const terminalHeader = document.createElement('div'); + const placeholder = document.createElement('div'); + function isPanelTerminalSession() { return false; } + function notePanelTerminalExit() {} + function noteSessionExit() {} + function lastSessionExit() { return { exitCode: 0, signal: null, stopped: true }; } + function exitBannerColour() { return ''; } + function exitBannerPhrase() { return ''; } + function destroySession() {} + function setActiveSession() {} + function refreshSidebar() {} + function schedulePersistWorkingSet() {} + function pollActiveSessions() {} + function setHasBusyAgents() {} + function setRemoteAttached() {} + function localTranscriptPtyTakeover() {} + function paintSessionIcon() {} + function isSessionAlive() { return false; } + function sessionItemEl(id) { return document.querySelector('.session-item[data-session-id="' + id + '"]'); } + function dropLocalPtySession(id, via) { calls.dropped.push([id, via]); } + function renderDefaultStatus() { calls.status++; } +`; + +function setup() { + const dom = new JSDOM('', { runScripts: 'outside-only' }); + const { window } = dom; + let exitHandler = null; + window.api = { onProcessExited: (cb) => { exitHandler = cb; } }; + const ctx = dom.getInternalVMContext(); + const run = (src, filename) => vm.runInContext(src, ctx, { filename }); + run(PRELUDE, 'prelude.js'); + run(sliceBlock('function updateRunningIndicators() {'), 'app.js#updateRunningIndicators'); + run(`function updateTerminalHeader() {}`, 'stub.js'); + run(sliceBlock('function applyProcessExit('), 'app.js#applyProcessExit'); + run(sliceBlock('function handleProcessExited('), 'app.js#handleProcessExited'); + run(sliceBlock('window.api.onProcessExited((', ');'), 'app.js#onProcessExited'); + return { + exit: (id) => exitHandler(id, 0, null, true), + read: (expr) => run(expr, 'read.js'), + window, + }; +} + +test('a busy signal that lands after the pty-set scan is cleared when the session is dropped', () => { + const ctx = setupSidebarDom(); + try { + const item = ctx.document.createElement('div'); + item.className = 'session-item'; + item.dataset.sessionId = 'dropped-1'; + item.innerHTML = ''; + ctx.document.getElementById('sidebar-content').append(item); + + ctx.setActivity('dropped-1', true, 'onCliBusyState'); + assert.ok(item.classList.contains('cli-busy'), 'precondition: the late busy signal marked the row'); + + ctx.window.dropLocalPtySession('dropped-1', 'process-exited'); + + assert.ok(!item.classList.contains('cli-busy'), 'the dropped row must stop reading as busy'); + assert.equal(ctx.sessionBusyState.has('dropped-1'), false, 'no activity state survives the drop'); + } finally { + ctx.destroy(); + } +}); + +test('dropping a session also clears its attention mark', () => { + const ctx = setupSidebarDom(); + try { + const item = ctx.document.createElement('div'); + item.className = 'session-item'; + item.dataset.sessionId = 'dropped-2'; + item.innerHTML = ''; + ctx.document.getElementById('sidebar-content').append(item); + + ctx.window.setAttention('dropped-2', true, 'onTerminalNotification'); + assert.ok(item.classList.contains('needs-attention'), 'precondition'); + + ctx.window.dropLocalPtySession('dropped-2', 'process-exited'); + + assert.ok(!item.classList.contains('needs-attention')); + assert.equal(ctx.attentionSessions.has('dropped-2'), false); + } finally { + ctx.destroy(); + } +}); + +test('process-exited removes the session from the running set and drops its activity state', () => { + const h = setup(); + h.read("activePtyIds = new Set(['a', 'b']); sessionMap.set('b', { sessionId: 'b', type: 'claude' })"); + h.exit('b'); + assert.equal(h.read('JSON.stringify(Array.from(activePtyIds))'), '["a"]', 'b no longer counts as running'); + assert.equal(h.read('JSON.stringify(calls.dropped)'), '[["b","process-exited"]]'); +}); + +test('process-exited leaves a remote row to its own adapter, even with no sessionMap entry', () => { + const h = setup(); + h.read("const row = document.createElement('div'); row.className = 'session-item'; row.dataset.sessionId = 'r'; row.dataset.remoteAlias = 'vps'; document.body.append(row)"); + h.exit('r'); + assert.equal(h.read('calls.dropped.length'), 0, 'a remote row is not purged by the local drop path'); +}); + +test('a change in the running set refreshes the status bar count, an unchanged one does not', () => { + const h = setup(); + h.read("activePtyIds = new Set(['a', 'b']); updateRunningIndicators()"); + assert.equal(h.read('calls.status'), 1, 'set changed: status bar redrawn'); + h.read('updateRunningIndicators()'); + assert.equal(h.read('calls.status'), 1, 'set unchanged: no redraw'); + h.read("activePtyIds = new Set(['a']); updateRunningIndicators()"); + assert.equal(h.read('calls.status'), 2, 'a session dropped by main: status bar redrawn'); +}); + +test('process-exited refreshes the status bar through the running-set change', () => { + const h = setup(); + h.read("activePtyIds = new Set(['a', 'b']); updateRunningIndicators(); sessionMap.set('b', { sessionId: 'b', type: 'claude' })"); + const before = h.read('calls.status'); + h.exit('b'); + assert.equal(h.read('calls.status'), before + 1); +}); diff --git a/test/open-session-terminal.test.js b/test/open-session-terminal.test.js index 169d9172..e68fd2e2 100644 --- a/test/open-session-terminal.test.js +++ b/test/open-session-terminal.test.js @@ -47,7 +47,10 @@ async function withHarness(setup, fn) { }, }); setup(window, calls); - const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession'] }); + const { openSession } = loadAppFunctions(ctx.context, { + declarations: ['ptyGenerations', 'pendingOpens'], + functions: ['beginPtyOpen', 'settlePtyOpen', 'openSession'], + }); await fn({ openSession, window, calls }); } finally { ctx.destroy(); diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index 7ddd9cc8..22995bc8 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -56,7 +56,7 @@ const PRELUDE = ` const placeholder = document.createElement('div'); const terminalHeaderStatus = document.createElement('span'); const terminalStopBtn = document.createElement('button'); - const calls = { writes: [], opened: [] }; + const calls = { writes: [], opened: [], dropped: [] }; let openResult = { ok: true }; function updatePtyTitle() {} function isPanelTerminalSession() { return false; } @@ -64,6 +64,11 @@ const PRELUDE = ` function destroySession(id) { openSessions.delete(id); } function setActiveSession() {} function refreshSidebar() {} + function dropLocalPtySession(id) { calls.dropped.push(id); } + function sessionItemEl() { return null; } + const ptyGenerations = new Map(); + const pendingOpens = new Map(); + function updateRunningIndicators() {} function schedulePersistWorkingSet() {} function pollActiveSessions() { updateTerminalHeader(); } async function guardResume() { return true; } @@ -94,11 +99,15 @@ function setup() { run(fs.readFileSync(path.join(ROOT, 'public', 'process-exit.js'), 'utf8'), 'process-exit.js'); run(sliceBlock('function updateTerminalHeader() {'), 'app.js#updateTerminalHeader'); run(sliceBlock('async function openSession(session'), 'app.js#openSession'); + run(sliceBlock('function applyProcessExit('), 'app.js#applyProcessExit'); + run(sliceBlock('function handleProcessExited('), 'app.js#handleProcessExited'); + run(sliceBlock('function beginPtyOpen('), 'app.js#beginPtyOpen'); + run(sliceBlock('function settlePtyOpen('), 'app.js#settlePtyOpen'); run(sliceBlock('window.api.onProcessExited((', ');'), 'app.js#onProcessExited'); run(`sessionMap.set('s1', { sessionId: 's1', projectPath: '/p', type: 'claude' }); openSessions.set('s1', makeEntry());`, 'fixture.js'); const read = (expr) => run(expr, 'read.js'); return Object.assign(h, { - exit: (code, signal, stopped) => exitHandler('s1', code, signal, stopped), + exit: (code, signal, stopped, generation) => exitHandler('s1', code, signal, stopped, generation), exitOf: (id, code) => exitHandler(id, code, null, false), poll: (running) => { read(running ? "activePtyIds.add('s1')" : "activePtyIds.delete('s1')"); read('updateTerminalHeader()'); }, title: () => read('terminalHeaderStatus.title'), @@ -132,6 +141,45 @@ test('a relaunch that fails to open shows no exit code from the process before i assert.equal(h.title(), 'Stopped'); }); +test('a stale exit that arrives after the reply of the relaunch leaves the new pty alone', async () => { + const h = setup(); + h.exit(1, null, false, 1); + h.read("openResult = { ok: true, generation: 2 }"); + await h.read("openSession(sessionMap.get('s1'))"); + h.read("activePtyIds.add('s1')"); + const writes = h.read('calls.writes.length'); + const dropped = h.read('calls.dropped.length'); + h.exit(9, null, false, 1); + assert.equal(h.read("openSessions.get('s1').closed"), false, 'the new pty is not marked closed'); + assert.equal(h.read('calls.writes.length'), writes, 'no banner for the old pty'); + assert.equal(h.read("activePtyIds.has('s1')"), true, 'the new pty stays in the running set'); + assert.equal(h.read('calls.dropped.length'), dropped, 'its live state is not purged'); +}); + +test('a stale exit that arrives before the reply is discarded once the reply names a newer generation', async () => { + const h = setup(); + h.exit(1, null, false, 1); + h.read("openResult = { ok: true, generation: 2 }"); + h.whileOpening = () => h.exit(9, null, false, 1); + await h.read("openSession(sessionMap.get('s1'))"); + h.read("activePtyIds.add('s1')"); + assert.equal(h.read("openSessions.get('s1').closed"), false, 'the new pty is not marked closed'); + assert.equal(h.read("activePtyIds.has('s1')"), true); +}); + +test('a fast-failing launch whose exit lands during the await is applied after the reply', async () => { + const h = setup(); + h.exit(1, null, false, 1); + h.read("openResult = { ok: true, generation: 2 }"); + h.whileOpening = () => h.exit(3, null, false, 2); + const droppedBefore = h.read('calls.dropped.length'); + await h.read("openSession(sessionMap.get('s1'))"); + assert.equal(h.read("openSessions.get('s1').closed"), true, 'the exit of the current pty closes its entry'); + assert.equal(h.title(), 'Exited (code 3)'); + assert.match(h.lastWrite(), /session exited \(code 3\)/); + assert.equal(h.read('calls.dropped.length'), droppedBefore + 1, 'and drops its state'); +}); + test('a relaunch that opens shows no old exit code before its first poll', async () => { const h = setup(); h.exit(1, null); @@ -201,12 +249,14 @@ test('main names the signal node-pty reports and forwards it with the exit code' assert.match(MAIN_SRC, /ptyProcess\.onExit\(\(\{ exitCode, signal \}\) => \{\s*const exitSignal = ptyExitSignalName\(signal\);/); const sends = MAIN_SRC.match(/webContents\.send\('process-exited', [^)]*\)/g); assert.equal(sends.length, 2); - for (const send of sends) assert.match(send, /, exitCode, exitSignal, stopped\)$/); + for (const send of sends) assert.match(send, /, exitCode, exitSignal, stopped, session\.generation\)$/); assert.match(MAIN_SRC, /const stopped = !!session\.stopRequested;/); const stopHandler = MAIN_SRC.slice(MAIN_SRC.indexOf("ipcMain.handle('stop-session'"), MAIN_SRC.indexOf("ipcMain.handle('remote-stop-session'")); assert.match(stopHandler, /session\.stopRequested = true;\s*killPty\(session, sessionId\);/, 'a Stop is recorded before the signal is sent'); assert.match(MAIN_SRC, /attachedSession\.stopRequested = true;\s*killPty\(attachedSession, sessionId\);/); - assert.match(PRELOAD_SRC, /'process-exited', \(_event, sessionId, exitCode, signal, stopped\) => callback\(sessionId, exitCode, signal, stopped\)/); + assert.ok(MAIN_SRC.includes('session.generation = ++ptyGenerationCounter;'), 'every wired pty gets the next generation'); + assert.equal(MAIN_SRC.match(/return \{\s*ok: true, reattached: (true|false), [^}]*generation: (session|remoteSession)\.generation,?\s*\}/g).length, 3, 'all three open-terminal replies carry the generation'); + assert.ok(PRELOAD_SRC.includes("'process-exited', (_event, sessionId, exitCode, signal, stopped, generation) => callback(sessionId, exitCode, signal, stopped, generation)")); assert.match(APP_SRC, /notePanelTerminalExit\(sessionId, exitCode, signal, stopped\)/); }); diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index 90ccf9b0..5ffeb59c 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -82,6 +82,8 @@ function setup({ savedSet, liveIds, batchFails = false }) { function setSessionMcpState() {} function setSessionSandboxed() {} function forgetSessionExit() {} + function beginPtyOpen() {} + function settlePtyOpen() {} function schedulePersistWorkingSet() {} function pollActiveSessions() {} `, ctx); diff --git a/test/running-indicators.test.js b/test/running-indicators.test.js index 7f09db0d..b1353a24 100644 --- a/test/running-indicators.test.js +++ b/test/running-indicators.test.js @@ -43,6 +43,7 @@ function withIndicators(fn) { const ctx = setupSidebarDom(); try { ctx.window.gridCards = new Map(); + ctx.window.renderDefaultStatus = () => {}; const { updateRunningIndicators } = loadAppFunctions(ctx.context, { declarations: ['_lastPtySignature'], functions: ['updateRunningIndicators'], diff --git a/test/session-activity.test.js b/test/session-activity.test.js index 328a6868..d7ec1c26 100644 --- a/test/session-activity.test.js +++ b/test/session-activity.test.js @@ -478,8 +478,8 @@ test('public/app.js: the pty-stop cleanup routes through purgeActivityFor and sk assert.match(body, /if\s*\(!running\s*&&\s*!item\.dataset\.remoteAlias\)\s*\{/, 'the purge branch must skip rows carrying dataset.remoteAlias'); - assert.match(body, /purgeActivityFor\(id,\s*'pty-gone'\)/, - 'the purge must go through the shared dispatcher'); + assert.match(body, /dropLocalPtySession\(id,\s*'pty-gone'\)/, + 'the purge must go through the shared drop helper'); assert.ok(!/sessionBusyState\.delete\(id\)/.test(body), 'app.js must not delete from sessionBusyState directly anymore'); assert.ok(!/responseReadySessions\.delete\(id\)/.test(body),