From 55c57c0281ee4ff8e46de1dd2b06a412b380bc2a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Tue, 13 Oct 2026 14:49:32 +0200 Subject: [PATCH 1/3] (restore): a quit during restore keeps neither a duplicate fresh session nor loses an attaching continuation (#550) A fresh restore item stayed in restoreInFlight while its new session was already pending, so a quit saved both. A continuation opening as attach was not yet keepAttached, so the held filter dropped it. Also covers clearOwner on the Windows process-table path. --- CHANGELOG.md | 1 + public/app.js | 11 ++- test/cli-session-state.test.js | 42 ++++++++++ test/restore-in-flight-persist.test.js | 104 +++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 test/restore-in-flight-persist.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f9f518d3..78919b6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### Fixed - On Windows, opening a project with different letter case no longer makes its scheduled tasks run twice. Existing duplicate registrations are cleaned up automatically. (#607) +- Quitting while startup is restoring sessions no longer opens an extra new session on the next start or forgets a conversation being reattached in the background. (#550) - On Windows, a `/compact` sent by an automation trigger is no longer reported as done because of a transcript file that was replaced while it waited. (#601) - A session sent to the background is reattached at every restart, not only the first. (#546) - Archiving a merged remote conversation now stops its live members even when the visible session is no longer running. Archiving a remote session whose host has not synced yet in this run (attached, or listed from the saved copy after a restart) now asks the host first, with the button busy, for up to 20 seconds. If the host does not answer, the session is still archived and a notice says it was not stopped and may still be running; archiving a folder or a group works the same way. A row whose host is disabled or removed archives as before. (#556) diff --git a/public/app.js b/public/app.js index 1e1f096c8..b8c3185ca 100644 --- a/public/app.js +++ b/public/app.js @@ -204,10 +204,12 @@ function persistWorkingSet({ final = false } = {}) { ...(fresh ? { fresh: true } : {}), }); } - const notStarted = pendingRestoreEntries().filter(({ item }) => !openSessions.has(item.sessionId)); + const liveAttach = (id) => { const open = openSessions.get(id); return !!open && !open.closed && !!open.attach; }; + const notStarted = pendingRestoreEntries() + .filter(({ item }) => !openSessions.has(item.sessionId) || liveAttach(item.sessionId)) + .map(entry => ({ ...entry, keepAttached: liveAttach(entry.item.sessionId) })); const heldActive = notStarted.find(({ item }) => item.active); if (heldActive) for (const e of set) e.active = false; - const liveAttach = (id) => { const open = openSessions.get(id); return !!open && !open.closed && !!open.attach; }; const held = [...skippedWorkingSetEntries.values(), ...notStarted] .filter(({ item, keepAttached }) => keepAttached ? liveAttach(item.sessionId) : !openSessions.has(item.sessionId)) .sort((a, b) => a.index - b.index); @@ -288,7 +290,7 @@ async function runRestore(list, { retryAfterIndexing = !restoreIndexingDone } = const options = await resolveDefaultSessionOptions(project); delete options.worktree; delete options.worktreeName; - const newId = await launchNewSession(project, options); + const newId = await launchNewSession(project, options, item.sessionId); if (newId) startedAs.set(item.sessionId, newId); } finally { restoreInFlight.delete(item.sessionId); @@ -1359,7 +1361,7 @@ async function loadProjects({ resort = false } = {}) { // rebindSidebarEvents, buildSessionItem, startRename) → sidebar.js -async function launchNewSession(project, sessionOptions) { +async function launchNewSession(project, sessionOptions, restoreSessionId) { const sessionId = crypto.randomUUID(); const projectPath = project.projectPath; const session = { @@ -1393,6 +1395,7 @@ async function launchNewSession(project, sessionOptions) { refreshSidebar(); const entry = createTerminalEntry(session); + if (restoreSessionId) restoreInFlight.delete(restoreSessionId); // Open terminal in main process with session options const result = await window.api.openTerminal(sessionId, projectPath, true, sessionOptions || null, entry.initialSize); diff --git a/test/cli-session-state.test.js b/test/cli-session-state.test.js index 9937dbd05..ef9d53535 100644 --- a/test/cli-session-state.test.js +++ b/test/cli-session-state.test.js @@ -568,3 +568,45 @@ test('clearOwner: walks the state file pid up to the PTY pid', () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('clearOwner: uses the Windows process table to identify the PTY owner', async () => { + const dir = mkTmp(); + try { + const created = n => String(134360000000000000n + BigInt(n) * 1000n); + let reads = 0; + cliSessionState.init({ + dir, activeSessions: new Map(), log: silentLog, platform: 'win32', + isProcessAlive: pid => pid !== 500, + readProcessTable: async () => { + reads++; + return new Map([ + [100, { ppid: 1, created: created(10) }], + [200, { ppid: 100, created: created(20) }], + [300, { ppid: 200, created: created(30) }], + [400, { ppid: 1, created: created(40) }], + ]); + }, + }); + writeState(dir, 300, { sessionId: 'cleared', status: 'idle', pidDomain: 'win32:anchor', procStart: created(30) }); + writeState(dir, 400, { sessionId: 'elsewhere', status: 'idle', pidDomain: 'win32:anchor', procStart: created(40) }); + writeState(dir, 500, { sessionId: 'dead', status: 'idle' }); + writeState(dir, 600, { sessionId: 'no-proc', status: 'idle' }); + + assert.equal(cliSessionState.clearOwner('cleared', 100), 'pending', 'the table has not arrived yet'); + await cliSessionState.refreshProcessTable(); + assert.equal(reads, 1); + assert.equal(cliSessionState.clearOwner('cleared', 100), 'mine'); + assert.equal(cliSessionState.clearOwner('CLEARED', 100), 'mine'); + assert.equal(cliSessionState.clearOwner('cleared', 300), 'mine', 'the PTY runs the descriptor writer'); + assert.equal(cliSessionState.clearOwner('elsewhere', 100), 'other'); + assert.equal(cliSessionState.clearOwner('dead', 100), 'pending'); + assert.equal(cliSessionState.clearOwner('absent', 100), 'pending'); + assert.equal(cliSessionState.clearOwner('no-proc', 100), 'pending', 'the pid is absent from the table'); + assert.equal(cliSessionState.clearOwner('cleared', undefined), 'pending'); + writeState(dir, 300, { sessionId: 'cleared', status: 'idle', pidDomain: 'win32:anchor', procStart: created(100000) }); + assert.equal(cliSessionState.clearOwner('cleared', 100), 'other', 'a reused pid is not the descriptor writer'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/restore-in-flight-persist.test.js b/test/restore-in-flight-persist.test.js new file mode 100644 index 000000000..43ea1efd1 --- /dev/null +++ b/test/restore-in-flight-persist.test.js @@ -0,0 +1,104 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const { setup } = require('./continuations-harness'); +const { loadAppFunctions } = require('./app-source'); +const { encodeProjectPath } = require('../encode-project-path'); + +function setupExitRestore(t, graph, options) { + const h = setup(t, graph, options); + h.dom.window.encodeProjectPath = encodeProjectPath; + vm.runInContext(` + var cachedProjects = [], cachedAllProjects = []; + function refreshSidebar() {} + `, h.ctx, { filename: __filename }); + Object.assign(h.app, loadAppFunctions(h.ctx, { + declarations: ['savedWorkingSetRead', 'persistWorkingSetTimer', 'exitingAppTimer', 'EXIT_FLUSH_GRACE_MS'], + functions: ['launchNewSession', 'flushStateForExit'], + })); + h.ctx.savedEntries = h.saved; + vm.runInContext(` + savedWorkingSetRead = true; + restoreSavedIndex = new Map(savedEntries.map((item, index) => [item.sessionId, index])); + `, h.ctx, { filename: __filename }); + return h; +} + +function holdTerminalOpen(h) { + let release; + const blocked = new Promise(resolve => { release = () => resolve({ ok: true }); }); + let reached; + const opened = new Promise(resolve => { reached = resolve; }); + const openTerminal = h.dom.window.api.openTerminal; + h.dom.window.api.openTerminal = async (...args) => { + await openTerminal(...args); + reached(args[0]); + return blocked; + }; + return { opened, release }; +} + +test('quitting while a fresh restore opens saves only the replacement session', { timeout: 9000 }, async t => { + const h = setupExitRestore(t, {}, { + savedEntries: [{ sessionId: 'cleared', projectPath: '/project', active: true, fresh: true }], + }); + const gate = holdTerminalOpen(h); + const restoring = h.app.runRestore(h.saved); + const newId = await gate.opened; + try { + await h.app.flushStateForExit(); + assert.deepEqual(h.settings().openWorkingSet, [ + { sessionId: newId, projectPath: '/project', active: false, fresh: true }, + ]); + } finally { + gate.release(); + await restoring; + } +}); + +test('quitting while one fresh restore opens keeps another fresh entry in the same project', { timeout: 9000 }, async t => { + const h = setupExitRestore(t, {}, { + savedEntries: [ + { sessionId: 'first', projectPath: '/project', active: false, fresh: true }, + { sessionId: 'second', projectPath: '/project', active: true, fresh: true }, + ], + }); + const gate = holdTerminalOpen(h); + const restoring = h.app.runRestore(h.saved); + const newId = await gate.opened; + try { + await h.app.flushStateForExit(); + assert.deepEqual(h.settings().openWorkingSet, [ + { sessionId: newId, projectPath: '/project', active: false, fresh: true }, + { sessionId: 'second', projectPath: '/project', active: true, fresh: true }, + ]); + } finally { + gate.release(); + await restoring; + } +}); + +test('quitting while a continuation attaches keeps the resolved background session at its saved position', { timeout: 9000 }, async t => { + const h = setupExitRestore(t, { old: ['new'], new: [], later: [] }, { + live: { new: { kind: 'bg', jobId: '1234abcd', cwd: '/bg', pid: 1 } }, + savedEntries: [ + { sessionId: 'old', projectPath: '/project', active: true }, + { sessionId: 'later', projectPath: '/project', active: false }, + ], + }); + const gate = holdTerminalOpen(h); + const restoring = h.app.runRestore(h.saved); + assert.equal(await gate.opened, 'new'); + try { + await h.app.flushStateForExit(); + assert.deepEqual(h.settings().openWorkingSet, [ + { sessionId: 'new', projectPath: '/project', active: true, bg: true }, + { sessionId: 'later', projectPath: '/project', active: false }, + ]); + } finally { + gate.release(); + await restoring; + } +}); From a11cf14ade2e1611d65b3984585f5ef1e264d0ad Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Tue, 13 Oct 2026 15:12:12 +0200 Subject: [PATCH 2/3] (restore): keep the saved active mark on a fresh entry while it opens (#550) launchNewSession drops the restoring item before the terminal opens, so a quit in that window saved the replacement as inactive and the next start focused another session. The mark now rides on the pending session until the open settles. --- docs/session-restore.md | 2 +- public/app.js | 10 +++++++++- test/restore-in-flight-persist.test.js | 24 +++++++++++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/session-restore.md b/docs/session-restore.md index 3d9bd1640..cd32e3d8b 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -69,7 +69,7 @@ the restore is still starting (`pendingRestoreEntries`). So quitting during a cold index, before answering the prompt, or in the middle of a restore keeps them for the next start, while a session you stopped during the restore is left out. The saved active marker stays on its session until the restore has -opened it. An exit before the saved set has even been read writes nothing. +opened it, and passes to the replacement of a fresh entry while that one opens. An entry still awaiting consent that you attach by hand is saved during the ask window. An exit before the saved set has even been read writes nothing. If the exit does not happen after all (an installer that fails to start), saving resumes after the 10 s, and the set is written then if anything asked diff --git a/public/app.js b/public/app.js index b8c3185ca..f05303eac 100644 --- a/public/app.js +++ b/public/app.js @@ -204,6 +204,10 @@ function persistWorkingSet({ final = false } = {}) { ...(fresh ? { fresh: true } : {}), }); } + if (!set.some(e => e.active)) { + const opening = set.find(e => pendingSessions.get(e.sessionId)?.openingActive); + if (opening) opening.active = true; + } const liveAttach = (id) => { const open = openSessions.get(id); return !!open && !open.closed && !!open.attach; }; const notStarted = pendingRestoreEntries() .filter(({ item }) => !openSessions.has(item.sessionId) || liveAttach(item.sessionId)) @@ -1395,10 +1399,14 @@ async function launchNewSession(project, sessionOptions, restoreSessionId) { refreshSidebar(); const entry = createTerminalEntry(session); - if (restoreSessionId) restoreInFlight.delete(restoreSessionId); + if (restoreSessionId) { + if (restoreInFlight.get(restoreSessionId)?.active) pendingSessions.get(sessionId).openingActive = true; + restoreInFlight.delete(restoreSessionId); + } // Open terminal in main process with session options const result = await window.api.openTerminal(sessionId, projectPath, true, sessionOptions || null, entry.initialSize); + delete pendingSessions.get(sessionId)?.openingActive; if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; diff --git a/test/restore-in-flight-persist.test.js b/test/restore-in-flight-persist.test.js index 43ea1efd1..0d34c735a 100644 --- a/test/restore-in-flight-persist.test.js +++ b/test/restore-in-flight-persist.test.js @@ -50,7 +50,29 @@ test('quitting while a fresh restore opens saves only the replacement session', try { await h.app.flushStateForExit(); assert.deepEqual(h.settings().openWorkingSet, [ - { sessionId: newId, projectPath: '/project', active: false, fresh: true }, + { sessionId: newId, projectPath: '/project', active: true, fresh: true }, + ]); + } finally { + gate.release(); + await restoring; + } +}); + +test('quitting while the saved active fresh entry opens keeps the replacement active ahead of a later fresh entry', { timeout: 9000 }, async t => { + const h = setupExitRestore(t, {}, { + savedEntries: [ + { sessionId: 'a', projectPath: '/project', active: true, fresh: true }, + { sessionId: 'b', projectPath: '/project', active: false, fresh: true }, + ], + }); + const gate = holdTerminalOpen(h); + const restoring = h.app.runRestore(h.saved); + const newId = await gate.opened; + try { + await h.app.flushStateForExit(); + assert.deepEqual(h.settings().openWorkingSet, [ + { sessionId: newId, projectPath: '/project', active: true, fresh: true }, + { sessionId: 'b', projectPath: '/project', active: false, fresh: true }, ]); } finally { gate.release(); From a9bead028b855813cb26c52457dad01cec9f337f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Tue, 13 Oct 2026 15:34:25 +0200 Subject: [PATCH 3/3] (restore): never save two active entries, and clear the opening mark when the open throws (#550) The opening-active mark is applied after the held entries are placed, only when nothing else is active. The mark is removed in a finally so a rejected openTerminal cannot leave it behind. --- public/app.js | 16 +++++---- test/restore-in-flight-persist.test.js | 45 ++++++++++++++++++++++++++ test/schedule-live-resume.test.js | 2 +- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/public/app.js b/public/app.js index f05303eac..c91327be1 100644 --- a/public/app.js +++ b/public/app.js @@ -204,10 +204,6 @@ function persistWorkingSet({ final = false } = {}) { ...(fresh ? { fresh: true } : {}), }); } - if (!set.some(e => e.active)) { - const opening = set.find(e => pendingSessions.get(e.sessionId)?.openingActive); - if (opening) opening.active = true; - } const liveAttach = (id) => { const open = openSessions.get(id); return !!open && !open.closed && !!open.attach; }; const notStarted = pendingRestoreEntries() .filter(({ item }) => !openSessions.has(item.sessionId) || liveAttach(item.sessionId)) @@ -221,6 +217,10 @@ function persistWorkingSet({ final = false } = {}) { const active = heldActive ? heldActive.item === item : !!keepAttached && item.sessionId === activeSessionId; set.splice(Math.min(index, set.length), 0, { sessionId: item.sessionId, projectPath: item.projectPath, active, ...(item.fresh ? { fresh: true } : {}), ...(item.bg || (keepAttached && liveAttach(item.sessionId)) ? { bg: true } : {}) }); } + if (!set.some(e => e.active)) { + const opening = set.find(e => pendingSessions.get(e.sessionId)?.openingActive); + if (opening) opening.active = true; + } global.openWorkingSet = set; await window.api.setSetting('global', global); }).catch((e) => { console.warn('[switchboard] failed to persist working set', e); }); @@ -1405,8 +1405,12 @@ async function launchNewSession(project, sessionOptions, restoreSessionId) { } // Open terminal in main process with session options - const result = await window.api.openTerminal(sessionId, projectPath, true, sessionOptions || null, entry.initialSize); - delete pendingSessions.get(sessionId)?.openingActive; + let result; + try { + result = await window.api.openTerminal(sessionId, projectPath, true, sessionOptions || null, entry.initialSize); + } finally { + delete pendingSessions.get(sessionId)?.openingActive; + } if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; diff --git a/test/restore-in-flight-persist.test.js b/test/restore-in-flight-persist.test.js index 0d34c735a..01bd4f0f3 100644 --- a/test/restore-in-flight-persist.test.js +++ b/test/restore-in-flight-persist.test.js @@ -124,3 +124,48 @@ test('quitting while a continuation attaches keeps the resolved background sessi await restoring; } }); + +test('quitting while a fresh active entry opens after an attached continuation keeps a single active entry', { timeout: 9000 }, async t => { + const h = setupExitRestore(t, { old: ['new'], new: [] }, { + live: { new: { kind: 'bg', jobId: '1234abcd', cwd: '/bg', pid: 1 } }, + savedEntries: [ + { sessionId: 'old', projectPath: '/project', active: false }, + { sessionId: 'a', projectPath: '/project', active: true, fresh: true }, + ], + }); + const openTerminal = h.dom.window.api.openTerminal; + let release; + const blocked = new Promise(resolve => { release = () => resolve({ ok: true }); }); + let reached; + const opened = new Promise(resolve => { reached = resolve; }); + let calls = 0; + h.dom.window.api.openTerminal = async (...args) => { + await openTerminal(...args); + if (++calls < 2) return { ok: true }; + reached(args[0]); + return blocked; + }; + const restoring = h.app.runRestore(h.saved); + const newId = await opened; + try { + await h.app.flushStateForExit(); + const set = h.settings().openWorkingSet; + assert.equal(set.filter(entry => entry.active).length, 1); + assert.deepEqual(set.find(entry => entry.sessionId === 'new'), { sessionId: 'new', projectPath: '/project', active: true, bg: true }); + assert.equal(set.find(entry => entry.sessionId === newId).active, false); + } finally { + release(); + await restoring; + } +}); + +test('a failed terminal open does not leave the active mark on the pending session', { timeout: 9000 }, async t => { + const h = setupExitRestore(t, {}, { + savedEntries: [{ sessionId: 'a', projectPath: '/project', active: true, fresh: true }], + }); + let newId; + h.dom.window.api.openTerminal = async id => { newId = id; throw new Error('boom'); }; + await assert.rejects(h.app.runRestore(h.saved), /boom/); + const pending = vm.runInContext('pendingSessions.get', h.ctx); + assert.equal(pending.call(vm.runInContext('pendingSessions', h.ctx), newId).openingActive, undefined); +}); diff --git a/test/schedule-live-resume.test.js b/test/schedule-live-resume.test.js index e081396e1..fceedd428 100644 --- a/test/schedule-live-resume.test.js +++ b/test/schedule-live-resume.test.js @@ -78,7 +78,7 @@ function setup(t, { sandbox = false } = {}) { vm.runInContext(read('public/utils.js'), ctx); vm.runInContext(read('public/resume-guard.js'), ctx, { filename: path.join(ROOT, 'public/resume-guard.js') }); vm.runInContext(` - var openSessions = new Map(); + var openSessions = new Map(), pendingSessions = new Map(); var sessionMap = new Map(); var activeSessionId = null; var restoringWorkingSet = false;