diff --git a/src/main/daemon/daemon-legacy-adapters.ts b/src/main/daemon/daemon-legacy-adapters.ts index 160ddd64c64..7d5e533cfb4 100644 --- a/src/main/daemon/daemon-legacy-adapters.ts +++ b/src/main/daemon/daemon-legacy-adapters.ts @@ -5,8 +5,14 @@ import { } from './daemon-launch-paths' import { parseDaemonPidFile } from './daemon-pid-file-parse' import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonClient } from './client' import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' -import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS } from './types' +import { + CLEAN_DISCONNECT_PROTOCOL_VERSION, + PREVIOUS_DAEMON_PROTOCOL_VERSIONS, + type ListSessionsResult, + type ShutdownIfIdleResult +} from './types' function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean { try { @@ -23,6 +29,40 @@ function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: numb } } +/** + * Retire an old daemon that has no live PTYs. Without this, every protocol + * generation remains resident after an update even when all of its sessions + * have exited (#9138). + */ +async function retireIdleLegacyDaemon( + socketPath: string, + tokenPath: string, + protocolVersion: number +): Promise { + const client = new DaemonClient({ socketPath, tokenPath, protocolVersion }) + try { + await client.ensureConnectedWithin(1_000) + const { sessions } = await client.request('listSessions', undefined, 1_000) + if (sessions.some((session) => session.isAlive)) { + return false + } + if (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + const result = await client.request('shutdownIfIdle', undefined, 1_000) + return result.retiring + } + // Older generations predate shutdownIfIdle; the inventory just proved that + // killing this daemon cannot take a live PTY with it. + await client.request('shutdown', { killSessions: true }, 1_000) + return true + } catch { + // A failed inventory is not evidence of idleness; preserve the adapter so + // its sessions remain adoptable on the next startup. + return false + } finally { + client.disconnect() + } +} + // Why: callers that own an isolated runtime namespace must keep discovery history out of app userData. export async function createLegacyDaemonAdapters( runtimeDir: string, @@ -48,6 +88,9 @@ export async function createLegacyDaemonAdapters( } continue } + if (await retireIdleLegacyDaemon(socketPath, tokenPath, protocolVersion)) { + continue + } // Keep old-protocol PTYs routed to their original daemon during upgrade; legacy adapters never respawn (new code would recreate stale env semantics). // historyPath is still needed for cleanup — without it a later v4 session reusing the same ID could false-restore stale scrollback.bin. adapters.push( diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index 70d2bc3e2e6..64443e418de 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -147,12 +147,16 @@ export function retireParkedTerminalTab(tabId: string): void { // strong scroll-intent keys here or every closed parked tab leaks one per // leaf for the renderer lifetime. for (const pane of capture.panes) { - releaseTerminalScrollIntentKey(pane.leafId) + releaseCapturedPaneScrollIntent(pane) } capturedPanesByTabId.delete(tabId) } } +function releaseCapturedPaneScrollIntent(pane: ParkedTerminalPaneCapture): void { + releaseTerminalScrollIntentKey(pane.leafId) +} + /** * Synchronously disposes any parked watcher subscribed to these PTYs. * Shutdown transactionally suspends dispatcher sidecars before teardown, then @@ -225,6 +229,11 @@ export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet } for (const [tabId, capture] of capturedPanesByTabId) { if (!liveWorktreeIds.has(capture.worktreeId)) { + for (const pane of capture.panes) { + // Worktree removal can bypass closeTab while panes are parked; release + // the same strong scroll-intent keys as explicit tab retirement. + releaseCapturedPaneScrollIntent(pane) + } capturedPanesByTabId.delete(tabId) } }