fix(daemon): decide adoptability from what the daemon can do now, in one place

Round five found the same question — can this daemon complete a hello right
now? — answered in three places with three different conclusions, because
'held' had been added as a fifth branch rather than as the classification the
other branches route through. Two of those answers were wrong, and both ended
in no daemon at all, which is the outcome 'held' exists to prevent.

- A daemon whose adoption hello had just failed was handed back tagged
  'degraded-new-pty-fallback'. Init skips the lease only for 'held', so it
  reopened the same connection, threw, and aborted startup — leaving the
  agents alive but unreachable and Manage Sessions -> Restart throwing.
- The pty-spawn-unhealthy arm ran first and claimed a successful hello proved
  adoptability, but that reading is from before the grace window. A daemon
  that answered at t=0 and went silent through thirty seconds of retries took
  that arm and threw the same way. Ask whether it is answering now, first.

The budget was a comment with a Date.now() beside it: one occupancy probe
could cost 50s, because the client's default is a 5s hello per connection
step plus a 30s request timeout. Bound the probe explicitly, start the clock
before the first one, and size the window so the whole path — health check,
pid verification, loop overshoot and process-table read — fits under the
startup fail-open with room to spare.

Also folds the four sibling branches onto the same occupancy resolution.
getAliveDaemonSessionCount was byte-identical to countLiveSessionsOverIpc, so
one concept had two implementations and only one of them had been fixed.

The Windows probe exclusions could never match: the warmup spawns COMSPEC, an
absolute path, against an exact-equality test on 'cmd.exe /c exit'. Compare
the program by basename and keep the argv tail exact.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-11 21:14:55 -07:00
co-authored by Orca
parent cb0f3e4633
commit f5abf2aa7b
5 changed files with 168 additions and 88 deletions
@@ -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,
+71 -16
View File
@@ -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<string, unknown>)(
...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<void>; 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()
+51 -55
View File
@@ -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<string, unknown> = {}
// 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<boolean> {
})
}
async function getAliveDaemonSessionCount(
socketPath: string,
tokenPath: string,
protocolVersion = PROTOCOL_VERSION
): Promise<number | null> {
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
try {
await client.ensureConnected()
const result = await client.request<ListSessionsResult>('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<DaemonOccupancy> {
return resolveDaemonOccupancy({ socketPath, tokenPath, recordedPid: null })
}
async function shouldPreserveDaemonWithLiveSessions(
socketPath: string,
tokenPath: string,
replacementLabel: string
): Promise<boolean> {
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(
+21 -8
View File
@@ -66,17 +66,30 @@ function withDeadline<T>(work: Promise<T>, 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
})
}
/**
+13 -2
View File
@@ -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<number | null> {
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion: PROTOCOL_VERSION })
try {
await client.ensureConnected()
const result = await client.request<ListSessionsResult>('listSessions', undefined)
await client.ensureConnectedWithin(OCCUPANCY_IPC_BUDGET_MS)
const result = await client.request<ListSessionsResult>(
'listSessions',
undefined,
OCCUPANCY_IPC_BUDGET_MS
)
return result.sessions.filter((session) => session.isAlive).length
} catch {
return null