fix(daemon): retire idle legacy generations after updates

This commit is contained in:
m4air
2026-09-15 18:01:26 -07:00
parent 62b77fce3c
commit cec495285a
2 changed files with 54 additions and 2 deletions
+44 -1
View File
@@ -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<boolean> {
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
try {
await client.ensureConnectedWithin(1_000)
const { sessions } = await client.request<ListSessionsResult>('listSessions', undefined, 1_000)
if (sessions.some((session) => session.isAlive)) {
return false
}
if (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) {
const result = await client.request<ShutdownIfIdleResult>('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(
@@ -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<string>
}
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)
}
}