fix(runtime): keep local watching alive after an idle-kill deadline miss

An idle child that outlived the exit deadline set shutdownRequested on the
shared desktop supervisor, which has no retire-and-replace path — every
later subscribe rejected supervisor_disposed and the roots were cached
unwatchable, silently ending local file watching for the session. The idle
path owns zero records, so there is no double-watch hazard; the zombie
keeps its capacity reservation until physical exit and the next subscribe
gets a fresh child.
This commit is contained in:
Brennan Benson
2026-07-14 00:45:05 -07:00
parent a635ff9a72
commit 5243acc7c5
2 changed files with 42 additions and 4 deletions
@@ -254,11 +254,12 @@ export class WatcherProcessSupervisor {
// Why: destructive Windows cleanup must await exit to release directory handles.
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
return this.terminationQueue.track(
termination.terminateIdleWatcherChild(proc, this.pendingUnsubscribes, (exited) => {
termination.terminateIdleWatcherChild(proc, this.pendingUnsubscribes, () => {
// Why: an idle child owns zero records, so a missed exit deadline has no
// double-watch hazard; the child keeps its capacity reservation until
// physical exit, and poisoning this supervisor would permanently end
// local watching — the shared singleton has no retire-and-replace path.
this.terminatingChild = null
if (!exited) {
this.shutdownRequested = true
}
})
)
}
@@ -83,6 +83,43 @@ describe('watcher native unsubscribe timeout', () => {
}
})
it('spawns a fresh child after the idle kill misses the exit deadline', async () => {
vi.useFakeTimers()
try {
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
const first = currentChild()
ackSubscribe(first, 0)
const subscription = await promise
const unsubscribe = subscription.unsubscribe().catch((error: unknown) => error)
expect(first.kill).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS)
expect(first.kill).toHaveBeenLastCalledWith('SIGKILL')
// Why: removal callers still fail closed on the unproven exit.
const error = (await unsubscribe) as Error & { physicalExit?: Promise<void> }
expect(error).toMatchObject({
message: 'file watcher process did not exit after termination deadline',
physicalExit: expect.any(Promise)
})
// Why: the idle child owned zero records, so its zombie must not poison
// the shared supervisor — the next subscribe gets a fresh child.
const respawnPromise = subscribeViaWatcherProcess('/repo2', vi.fn(), {})
expect(forkMock).toHaveBeenCalledTimes(2)
const second = currentChild()
expect(second).not.toBe(first)
expect(second.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo2' })
ackSubscribe(second)
await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) })
first.emit('exit', null, 'SIGKILL')
await error.physicalExit
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('rejects only after an unkillable child reaches its exit deadline', async () => {
vi.useFakeTimers()
try {