mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user