fix(daemon): bound legacy probes and keep host under lint limit

This commit is contained in:
Brennan Benson
2026-07-10 17:42:54 -07:00
parent 1c57de85be
commit 23b98e62a3
4 changed files with 139 additions and 71 deletions
+47
View File
@@ -845,6 +845,53 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(killStaleDaemonMock).not.toHaveBeenCalled()
})
it('bounds an unresponsive legacy session query and fail-safe adopts the daemon', async () => {
vi.useFakeTimers()
try {
const mod = await importFresh()
probeSocketExistsMock.mockImplementation(
(p?: string) => p?.endsWith('daemon-v9.sock') ?? false
)
netConnectMock.mockImplementation(() => {
const handlers: Record<string, (() => void)[]> = { connect: [], error: [] }
return {
on(event: string, cb: () => void) {
handlers[event]?.push(cb)
if (event === 'connect') {
queueMicrotask(() => cb())
}
return this
},
removeListener(event: string, cb: () => void) {
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
return this
},
destroy() {}
}
})
const disconnectMock = vi.fn()
daemonClientMock.mockImplementationOnce(function MockHungLegacyDaemonClient() {
return {
ensureConnected: vi.fn(async () => {}),
request: vi.fn(() => new Promise<never>(() => {})),
disconnect: disconnectMock
}
})
const initPromise = mod.initDaemonPtyProvider()
await vi.advanceTimersByTimeAsync(1000)
await initPromise
const { DaemonPtyRouter } = await import('./daemon-pty-router')
expect(mod.getDaemonProvider()).toBeInstanceOf(DaemonPtyRouter)
expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true)
expect(disconnectMock).toHaveBeenCalledOnce()
expect(killStaleDaemonMock).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('restart path with no legacy adapters yields a bare DaemonPtyAdapter (not wrapped in a router)', async () => {
const mod = await importFresh()
await mod.initDaemonPtyProvider()
+21 -2
View File
@@ -74,6 +74,7 @@ let adapter: DaemonProvider | null = null
// the second entry would read the already-disposed current adapter and
// race cleanupDaemonForProtocol against a half-spawned replacement.
let restartInFlight: Promise<RestartDaemonResult> | null = null
const LEGACY_SESSION_QUERY_TIMEOUT_MS = 1000
function getRuntimeDir(): string {
const dir = join(app.getPath('userData'), 'daemon')
@@ -160,13 +161,31 @@ async function getAliveDaemonSessionCount(
protocolVersion = PROTOCOL_VERSION
): Promise<number | null> {
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
let timeout: ReturnType<typeof setTimeout> | null = null
try {
await client.ensureConnected()
const result = await client.request<ListSessionsResult>('listSessions', undefined)
// Why: legacy versions are checked serially during startup. A daemon that
// accepts sockets but stops answering RPCs must fail-safe to adoption
// promptly instead of multiplying the client's 30s timeout by versions.
const result = await Promise.race([
(async () => {
await client.ensureConnected()
return client.request<ListSessionsResult>('listSessions', undefined)
})(),
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), LEGACY_SESSION_QUERY_TIMEOUT_MS)
timeout.unref?.()
})
])
if (!result) {
return null
}
return result.sessions.filter((session) => session.isAlive).length
} catch {
return null
} finally {
if (timeout) {
clearTimeout(timeout)
}
client.disconnect()
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { SubprocessHandle } from './session'
import type { ShellReadyState, TakePendingOutputResult, TerminalSnapshot } from './types'
export type CreateOrAttachOptions = {
sessionId: string
cols: number
rows: number
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
startupCommandDelivery?: StartupCommandDelivery
/** Explicit shell the renderer asked for (e.g. 'wsl.exe' for "New WSL
* terminal" from the "+" menu). Forwarded to the subprocess spawner so the
* daemon path honors per-tab shell selection the same way LocalPtyProvider
* does. */
shellOverride?: string
terminalWindowsWslDistro?: string | null
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
shellReadySupported?: boolean
shellReadyTimeoutMs?: number
historySeed?: string
streamClient: { onData: (data: string) => void; onExit: (code: number) => void }
}
export type CreateOrAttachResult = {
isNew: boolean
snapshot: TerminalSnapshot | null
pid: number | null
shellState: ShellReadyState
historySeeded?: boolean
attachToken: symbol
}
export type TerminalHostOptions = {
spawnSubprocess: (opts: {
sessionId: string
cols: number
rows: number
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
startupCommandDelivery?: StartupCommandDelivery
shellOverride?: string
terminalWindowsWslDistro?: string | null
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
}) => SubprocessHandle
// Why: on graceful shutdown, the host writes final checkpoints for all live
// sessions before killing them. This bypasses the RPC round-trip — the daemon
// writes checkpoints in-process, guaranteeing completion before teardown.
onFinalCheckpoint?: (
sessionId: string,
snapshot: TerminalSnapshot,
records: TakePendingOutputResult['records']
) => void
// Why: production keeps a large cap, but tests need a small deterministic cap
// without spawning thousands of full terminal sessions.
maxTombstones?: number
// Why: lets the daemon server re-evaluate idle-exit eligibility on every
// session-map transition instead of polling the host.
onSessionCountChange?: () => void
}
+7 -69
View File
@@ -1,79 +1,17 @@
import { Session, type SubprocessHandle } from './session'
import { Session } from './session'
import { normalizePtySize } from './daemon-pty-size'
import { resolveProcessCwd } from '../providers/process-cwd'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import type {
SessionInfo,
TakePendingOutputResult,
TerminalSnapshot,
ShellReadyState
} from './types'
import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types'
import { SessionNotFoundError } from './types'
import type {
CreateOrAttachOptions,
CreateOrAttachResult,
TerminalHostOptions
} from './terminal-host-contract'
const DEFAULT_MAX_TOMBSTONES = 1000
export type CreateOrAttachOptions = {
sessionId: string
cols: number
rows: number
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
startupCommandDelivery?: StartupCommandDelivery
/** Explicit shell the renderer asked for (e.g. 'wsl.exe' for "New WSL
* terminal" from the "+" menu). Forwarded to the subprocess spawner so the
* daemon path honors per-tab shell selection the same way LocalPtyProvider
* does. */
shellOverride?: string
terminalWindowsWslDistro?: string | null
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
shellReadySupported?: boolean
shellReadyTimeoutMs?: number
historySeed?: string
streamClient: { onData: (data: string) => void; onExit: (code: number) => void }
}
export type CreateOrAttachResult = {
isNew: boolean
snapshot: TerminalSnapshot | null
pid: number | null
shellState: ShellReadyState
historySeeded?: boolean
attachToken: symbol
}
export type TerminalHostOptions = {
spawnSubprocess: (opts: {
sessionId: string
cols: number
rows: number
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
startupCommandDelivery?: StartupCommandDelivery
shellOverride?: string
terminalWindowsWslDistro?: string | null
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
}) => SubprocessHandle
// Why: on graceful shutdown, the host writes final checkpoints for all live
// sessions before killing them. This bypasses the RPC round-trip — the daemon
// writes checkpoints in-process, guaranteeing completion before teardown.
onFinalCheckpoint?: (
sessionId: string,
snapshot: TerminalSnapshot,
records: TakePendingOutputResult['records']
) => void
// Why: production keeps a large cap, but tests need a small deterministic cap
// without spawning thousands of full terminal sessions.
maxTombstones?: number
// Why: lets the daemon server re-evaluate idle-exit eligibility on every
// session-map transition instead of polling the host.
onSessionCountChange?: () => void
}
export class TerminalHost {
private sessions = new Map<string, Session>()
private killedTombstones = new Map<string, number>()