diff --git a/config/scripts/daemon-replacement-live-agent-pty-preservation-repro.mjs b/config/scripts/daemon-replacement-live-agent-pty-preservation-repro.mjs index 8d99c0e44df..8dcc0697c25 100644 --- a/config/scripts/daemon-replacement-live-agent-pty-preservation-repro.mjs +++ b/config/scripts/daemon-replacement-live-agent-pty-preservation-repro.mjs @@ -570,14 +570,19 @@ function checkLauncherHoldsOccupiedDaemon() { `phase 3: ${relativePath}:${lineOf(source, holdDecl.index)} holdIncumbentDaemon() = ${normalize(holdBody.text)}` ) - // 2. The failed-health-check path resolves occupancy from an identity-verified pid. + // 2. Process-table evidence is only ever raised from an identity-verified pid — otherwise + // it could describe a recycled pid's children rather than this daemon's terminals. const verifiedPidCall = source.search(/readVerifiedDaemonPid\s*\(/) - const occupancyCall = source.search(/resolveDaemonOccupancy\s*\(\s*\{/) + const evidenceCall = source.match(/raiseOccupancyWithProcessEvidence\s*\(([^)]*)\)/) assert(verifiedPidCall !== -1, `${relativePath} never calls readVerifiedDaemonPid()`) - assert(occupancyCall !== -1, `${relativePath} never calls resolveDaemonOccupancy()`) + assert(evidenceCall !== null, `${relativePath} never raises occupancy with process evidence`) assert( - verifiedPidCall < occupancyCall, - `${relativePath} resolves occupancy before verifying the recorded pid — the evidence could then describe a recycled pid's children` + verifiedPidCall < evidenceCall.index, + `${relativePath} raises occupancy with process evidence before verifying the recorded pid` + ) + assert( + /verifiedPid\b/.test(evidenceCall[1]), + `${relativePath} passes a pid to raiseOccupancyWithProcessEvidence that is not the verified one: ${normalize(evidenceCall[1])}` ) // 3. The 'occupied' branch holds and never kills. @@ -611,7 +616,7 @@ function checkLauncherHoldsOccupiedDaemon() { `${relativePath}:${occupiedLine} does not return holdIncumbentDaemon() when the session count came from the process table` ) log( - `phase 3: ${relativePath}:${occupiedLine} occupancy.state === 'occupied' && occupancy.liveSessions === null -> return holdIncumbentDaemon(); the branch contains no kill` + `phase 3: ${relativePath}:${occupiedLine} occupancy.state === 'occupied' + cannot-be-adopted -> return holdIncumbentDaemon(); the branch contains no kill` ) // 4. Ordering: every kill on this path is downstream of the occupied branch, so a hold @@ -619,7 +624,7 @@ function checkLauncherHoldsOccupiedDaemon() { const killCalls = [...source.matchAll(/killStaleDaemon\s*\(/g)].map((match) => match.index) assert(killCalls.length > 0, `${relativePath} never calls killStaleDaemon()`) const killsBeforeTheDecision = killCalls.filter( - (index) => index > occupancyCall && index < occupiedBlock.end + (index) => index > evidenceCall.index && index < occupiedBlock.end ) assert( killsBeforeTheDecision.length === 0, diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 70580c339a3..730ad7aa67f 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -314,7 +314,19 @@ vi.mock('./daemon-health', () => ({ parseDaemonPidFile: parseDaemonPidFileMock })) -vi.mock('./client', () => ({ DaemonClient: daemonClientMock })) +vi.mock('./client', () => ({ + // Mirror ensureConnected onto the bounded variant so every existing double keeps its + // behaviour — including the ones whose whole point is that connecting throws. + DaemonClient: function DaemonClientDouble(...args: unknown[]) { + const instance = (daemonClientMock as unknown as (...a: unknown[]) => Record)( + ...args + ) + if (instance && typeof instance === 'object' && !('ensureConnectedWithin' in instance)) { + instance.ensureConnectedWithin = instance.ensureConnected + } + return instance + } +})) vi.mock('./daemon-lifecycle-event', () => ({ trackDaemonReplaced: trackDaemonReplacedMock, @@ -1794,7 +1806,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { '/fake/token', FAKE_DAEMON_ENTRY_PATH ) - expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() @@ -1828,7 +1840,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') - expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() @@ -1927,7 +1939,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token') - expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() expect(killStaleDaemonMock).not.toHaveBeenCalled() @@ -1964,7 +1976,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') - expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() @@ -2905,7 +2917,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') - expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() @@ -2943,7 +2955,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const handle = await launcher('/fake/socket', '/fake/token') - expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(handle.mode).toBe('degraded-new-pty-fallback') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() @@ -3245,10 +3257,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) - it('adopts a PTY-spawn-unhealthy daemon in degraded mode instead of merely holding it', async () => { - // Why not 'held': 'pty-spawn-unhealthy' is only reachable after a successful hello, so this - // daemon CAN be adopted — it just cannot open new PTYs. Holding it (which never adopts) - // because the occupancy count happened to come from the process table strands its sessions. + it('adopts a PTY-spawn-unhealthy daemon in degraded mode when it can still answer', async () => { + // Why the count must come from IPC: an answered listSessions is what proves the daemon + // can still complete a handshake, and adoption opens one. const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -3259,13 +3270,55 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { disconnect: vi.fn() } } - // Hello succeeds; only listSessions is unanswerable, so occupancy falls back to the table. - daemonClientMock.mockImplementation(function MockPtySpawnUnhealthyClient() { + daemonClientMock.mockImplementation(function MockAnsweringDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [{ isAlive: true }] })), + getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), + disconnect: vi.fn() + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise; mode?: string }> + checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') + probeSocketExistsMock.mockReturnValue(true) + netConnectMock.mockImplementation(stubAliveSocketConnect) + + try { + const handle = await launcher('/fake/socket', '/fake/token') + + expect(handle.mode).toBe('degraded-new-pty-fallback') + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + } finally { + daemonClientMock.mockImplementation(answeringDefault) + } + }) + + it('holds a PTY-spawn-unhealthy daemon that has since stopped answering', async () => { + // Why: `health` is a reading from before the grace window. If listSessions went + // unanswered across all of it, adoption would open a hello the daemon can no longer + // complete — and that throw costs the app its daemon. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const answeringDefault = function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + } + } + daemonClientMock.mockImplementation(function MockSilentDaemonClient() { return { ensureConnected: vi.fn(async () => {}), request: vi.fn(async () => { throw new Error('listSessions timed out') }), + getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), disconnect: vi.fn() } }) @@ -3283,7 +3336,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { try { const handle = await launcher('/fake/socket', '/fake/token') - expect(handle.mode).toBe('degraded-new-pty-fallback') + expect(handle.mode).toBe('held') expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() } finally { @@ -3333,7 +3386,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { try { const handle = await launcher('/fake/socket', '/fake/token') - expect(handle.mode).toBe('degraded-new-pty-fallback') + // 'held', not merely degraded: init must not attempt a lease on a daemon whose + // adoption hello just failed — that throw would cost the app its daemon entirely. + expect(handle.mode).toBe('held') expect(forkMock).not.toHaveBeenCalled() } finally { daemonClientMock.mockImplementation(answeringDefault) @@ -3833,7 +3888,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { '/fake/token', '1.2.3' ) - expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined, expect.any(Number)) expect(disconnectMock).toHaveBeenCalledOnce() expect(killStaleDaemonMock).not.toHaveBeenCalled() expect(forkMock).not.toHaveBeenCalled() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index e0c210963de..1c540adcbb9 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -20,7 +20,11 @@ import { } from './daemon-spawner' import { DAEMON_EXIT_ENDPOINT_OCCUPIED } from './daemon-endpoint-ownership' import { endpointIsProvenDead, probeSocketConnect } from './daemon-endpoint-probe' -import { raiseOccupancyWithProcessEvidence, resolveDaemonOccupancy } from './daemon-occupancy' +import { + raiseOccupancyWithProcessEvidence, + resolveDaemonOccupancy, + type DaemonOccupancy +} from './daemon-occupancy' import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' @@ -77,11 +81,14 @@ function logDaemonMilestone(event: string, details: Record = {} // Why: extra hello+listSessions probes (~5s each) giving a wedged-but-connectable daemon ~60s grace to answer and keep its live sessions before a permanent wedge (#8689) is replaced; raise only alongside the fail-open cap. export const WEDGED_DAEMON_GRACE_RETRIES = 11 /** - * Wall-clock ceiling on the grace window. The retry count alone does not bound it — each - * probe waits the client's hello timeout — and startup fails open at 60s, abandoning the - * daemon provider entirely. Overrunning that would trade a wedged daemon for no daemon. + * Wall-clock ceiling on the grace window, counted from before the first probe. The retry + * count alone does not bound it, and the ceiling is only tested at loop entry, so the real + * cost is this plus one in-flight probe plus the health check, pid verification and process + * table read on either side of it. Startup fails open at 60s by abandoning the daemon + * provider outright, so the whole path has to fit under that with room to spare — + * overrunning it would trade a wedged daemon for no daemon at all. */ -export const WEDGED_DAEMON_GRACE_BUDGET_MS = 30_000 +export const WEDGED_DAEMON_GRACE_BUDGET_MS = 20_000 const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000 const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 @@ -183,23 +190,6 @@ function probeSocket(socketPath: string): Promise { }) } -async function getAliveDaemonSessionCount( - socketPath: string, - tokenPath: string, - protocolVersion = PROTOCOL_VERSION -): Promise { - const client = new DaemonClient({ socketPath, tokenPath, protocolVersion }) - try { - await client.ensureConnected() - const result = await client.request('listSessions', undefined) - return result.sessions.filter((session) => session.isAlive).length - } catch { - return null - } finally { - client.disconnect() - } -} - function createPreservedDaemonHandle( runtimeDir: string, protocolVersion = PROTOCOL_VERSION, @@ -430,19 +420,30 @@ function isNoSuchProcessError(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' } +/** How a preserve decision reads in a log line, from either evidence source. */ +function describeOccupancy(occupancy: DaemonOccupancy): string { + if (occupancy.liveSessions === null) { + return 'live session state could not be verified' + } + return `it owns ${occupancy.liveSessions} live session${occupancy.liveSessions === 1 ? '' : 's'}` +} + +/** IPC only: these callers run against a daemon that just answered a health check. */ +function resolveOccupancyOverIpc(socketPath: string, tokenPath: string): Promise { + return resolveDaemonOccupancy({ socketPath, tokenPath, recordedPid: null }) +} + async function shouldPreserveDaemonWithLiveSessions( socketPath: string, tokenPath: string, replacementLabel: string ): Promise { - const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - if (liveSessionCount === 0) { + const occupancy = await resolveOccupancyOverIpc(socketPath, tokenPath) + if (occupancy.state === 'empty') { return false } console.warn( - liveSessionCount === null - ? `[daemon] Preserving daemon ${replacementLabel} because live session state could not be verified` - : `[daemon] Preserving daemon ${replacementLabel} because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + `[daemon] Preserving daemon ${replacementLabel} because ${describeOccupancy(occupancy)}` ) return true } @@ -514,19 +515,17 @@ function createOutOfProcessLauncher( if (health === 'healthy') { const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) if (resolverHealth === 'unhealthy') { - const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - if (liveSessionCount !== 0) { + const occupancy = await resolveOccupancyOverIpc(socketPath, tokenPath) + if (occupancy.state !== 'empty') { console.warn( - liveSessionCount === null - ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' - : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + `[daemon] Preserving daemon with unavailable macOS system resolver because ${describeOccupancy(occupancy)}` ) return preserveDaemon() } console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') pendingReplacement = { reason: 'unhealthy_resolver', - liveSessionCount + liveSessionCount: 0 } confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)) .cleaned @@ -578,12 +577,12 @@ function createOutOfProcessLauncher( if (attributionHealth === 'severed') { // Why: replacing with live sessions would kill them; Settings → Developer // Permissions surfaces the Manage Sessions → Restart remedy instead. - const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - if (liveSessionCount === 0) { + const occupancy = await resolveOccupancyOverIpc(socketPath, tokenPath) + if (occupancy.state === 'empty') { console.warn( '[daemon] Replacing daemon whose macOS TCC attribution is severed (spawning app binary no longer exists)' ) - pendingReplacement = { reason: 'severed_tcc_attribution', liveSessionCount } + pendingReplacement = { reason: 'severed_tcc_attribution', liveSessionCount: 0 } confirmedReplacement = ( await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) ).cleaned @@ -603,10 +602,10 @@ function createOutOfProcessLauncher( // Why recordedPid is withheld here: the loop below is waiting for *IPC* to recover, // and the process table cannot change its answer within a grace window. Scanning it // every pass would multiply the launch budget for an answer we already have. + const graceDeadline = Date.now() + WEDGED_DAEMON_GRACE_BUDGET_MS let occupancy = await resolveDaemonOccupancy({ socketPath, tokenPath, recordedPid: null }) // Why: a wedged-but-connectable daemon (Windows update relaunch) may still own live sessions, so grace-retry before replacing; a permanent wedge (#8689) exhausts the grace, and 'rejected' skips it (handshake refused = never adoptable). let graceRetry = 0 - const graceDeadline = Date.now() + WEDGED_DAEMON_GRACE_BUDGET_MS while ( occupancy.state === 'unknown' && health !== 'rejected' && @@ -625,25 +624,26 @@ function createOutOfProcessLauncher( occupancy.liveSessions === null ? 'live terminal processes' : `${occupancy.liveSessions} live session${occupancy.liveSessions === 1 ? '' : 's'}` - if (health === 'pty-spawn-unhealthy') { - // Reached only after a successful hello, so this daemon can still be adopted — - // it just cannot open new PTYs. - console.warn( - `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${owned}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).` - ) - return preserveDaemon('degraded-new-pty-fallback') - } - // Why hold rather than adopt: adoption opens a hello, and neither of these daemons - // can complete one — 'rejected' answered and refused, and a count only the process - // table could supply means nothing answered at all. Attempting it would throw and - // cost the app its daemon entirely. Holding keeps their terminals alive and routes - // fresh ones locally until they recover or the user restarts them. + + // Why this comes first: adoption opens a hello, and neither of these daemons can + // complete one — 'rejected' answered and refused, and a count only the process table + // could supply means nothing answered across the whole grace window. `health` is a + // reading from before that window, so it cannot overrule them. Attempting adoption + // anyway throws, and the throw costs the app its daemon entirely. if (health === 'rejected' || occupancy.liveSessions === null) { console.warn( `[daemon] DEGRADED MODE: holding a daemon that cannot be adopted (health=${health}) but still owns ${owned}. Killing it would end them; fresh terminals run on the local provider WITHOUT daemon persistence until it recovers or you restart it (Manage Sessions → Restart).` ) return holdIncumbentDaemon() } + if (health === 'pty-spawn-unhealthy') { + // It answered listSessions just now, so it is adoptable — it simply cannot open + // new PTYs. + console.warn( + `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${owned}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).` + ) + return preserveDaemon('degraded-new-pty-fallback') + } console.warn( `[daemon] Preserving daemon that failed the health check because it owns ${owned}` ) @@ -683,11 +683,7 @@ function createOutOfProcessLauncher( // cannot fire on a daemon that still owns sessions, and throwing here would cost the // app its daemon handle — taking Manage Sessions → Restart down with it. if (!endpointIsProvenDead(await probeSocketConnect(socketPath))) { - return createPreservedDaemonHandle( - runtimeDir, - PROTOCOL_VERSION, - 'degraded-new-pty-fallback' - ) + return createPreservedDaemonHandle(runtimeDir, PROTOCOL_VERSION, 'held') } // It died between the probe and the adoption; the endpoint is genuinely free now. throw new DaemonEndpointOwnershipError( diff --git a/src/main/daemon/daemon-live-pty-evidence.ts b/src/main/daemon/daemon-live-pty-evidence.ts index d2371e78cf0..5eaf5f63404 100644 --- a/src/main/daemon/daemon-live-pty-evidence.ts +++ b/src/main/daemon/daemon-live-pty-evidence.ts @@ -66,17 +66,30 @@ function withDeadline(work: Promise, deadlineMs: number, onDeadline: T): P */ const DAEMON_SELF_SPAWNED_PTY_COMMANDS = [ // pty-subprocess.ts checkPtySpawnHealth - '/bin/sh -c exit 0', - // windows-conpty-warmup.ts - 'cmd.exe /c exit', - 'cmd /c exit' + { program: 'sh', args: '-c exit 0' }, + // windows-conpty-warmup.ts, which spawns COMSPEC — an absolute path on a stock install + { program: 'cmd', args: '/c exit' } ] +/** Trailing path segment, with any Windows executable suffix removed. */ +function programBasename(command: string): string { + const base = command.split(/[\\/]/).pop() ?? command + return base.endsWith('.exe') ? base.slice(0, -'.exe'.length) : base +} + function isDaemonSelfSpawnedPty(row: ProcessTableRow): boolean { - const command = row.command.trim() - return DAEMON_SELF_SPAWNED_PTY_COMMANDS.some( - (probe) => command.toLowerCase() === probe.toLowerCase() - ) + const command = row.command.trim().toLowerCase() + return DAEMON_SELF_SPAWNED_PTY_COMMANDS.some(({ program, args }) => { + const suffix = ` ${args}` + // Why the argv tail must match exactly: `sh -c` payloads are user-supplied, and treating + // one as the daemon's own probe would discard proof that real work is running. + if (!command.endsWith(suffix)) { + return false + } + // Only the program may vary, and only by path — COMSPEC is absolute, node-pty execs + // '/bin/sh' verbatim — so it is compared by basename rather than by the whole string. + return programBasename(command.slice(0, command.length - suffix.length)) === program + }) } /** diff --git a/src/main/daemon/daemon-occupancy.ts b/src/main/daemon/daemon-occupancy.ts index 1953151b9de..e86434a3500 100644 --- a/src/main/daemon/daemon-occupancy.ts +++ b/src/main/daemon/daemon-occupancy.ts @@ -20,6 +20,13 @@ export type DaemonOccupancyDeps = { inspectPtyOwnership?: typeof inspectDaemonPtyOwnership } +/** + * Why an explicit budget: the defaults are a 5s hello *per connection step* plus a 30s + * request timeout, so one unanswered question can cost 50s. This runs inside a launch that + * fails open at a minute, and a caller that asks repeatedly needs each ask to be bounded. + */ +export const OCCUPANCY_IPC_BUDGET_MS = 4_000 + /** Live session count over the daemon's own socket; null when it could not answer. */ async function countLiveSessionsOverIpc( socketPath: string, @@ -27,8 +34,12 @@ async function countLiveSessionsOverIpc( ): Promise { const client = new DaemonClient({ socketPath, tokenPath, protocolVersion: PROTOCOL_VERSION }) try { - await client.ensureConnected() - const result = await client.request('listSessions', undefined) + await client.ensureConnectedWithin(OCCUPANCY_IPC_BUDGET_MS) + const result = await client.request( + 'listSessions', + undefined, + OCCUPANCY_IPC_BUDGET_MS + ) return result.sessions.filter((session) => session.isAlive).length } catch { return null