fix(daemon): keep shutdown terminating when disposal cannot prove exit

A rejecting host.dispose() (unreapable child past its exit deadline) left
the shutdown RPC without its process.nextTick(shutdown) and skipped socket
cleanup in shutdown(), stranding the daemon as an unreachable orphan after
the stale-daemon replacement flow unlinks its socket. Log and continue:
daemon exit reparents the child to init instead of blocking on it.
This commit is contained in:
Brennan Benson
2026-07-14 00:44:45 -07:00
parent a97e3cdac8
commit a635ff9a72
2 changed files with 42 additions and 3 deletions
+23 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect, type Server, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
import { existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs'
import { DaemonServer } from './daemon-server'
import { DaemonClient } from './client'
import { encodeNdjson } from './ndjson'
@@ -626,5 +626,27 @@ describe('DaemonServer', () => {
const c = new DaemonClient({ socketPath, tokenPath })
await expect(c.ensureConnected()).rejects.toThrow()
})
it('still terminates via the shutdown RPC when disposal cannot prove physical exit', async () => {
await startServer()
const daemon = server as unknown as DaemonServerPrivate & {
host: { dispose: () => Promise<void> }
}
// Why: an unreapable child rejects dispose after its exit deadline; the
// daemon must exit anyway or its replacement flow strands it as an orphan.
daemon.host.dispose = vi.fn(() =>
Promise.reject(new Error('Timed out waiting for physical PTY exit'))
)
const c = await connectClient()
// The daemon may self-terminate before the reply flushes; callers treat
// that as success, so only the observable teardown below is asserted.
await c.request('shutdown', { killSessions: true }).catch(() => {})
await waitFor(() => daemon.server === null)
await waitFor(() => !existsSync(socketPath))
const late = new DaemonClient({ socketPath, tokenPath })
await expect(late.ensureConnected()).rejects.toThrow()
})
})
})
+19 -2
View File
@@ -149,7 +149,15 @@ export class DaemonServer {
async shutdown(): Promise<void> {
this.stopStreamBacklogProbe()
this.transientFactRelay.dispose()
await this.host.dispose()
try {
await this.host.dispose()
} catch (err) {
// Why: an unreapable child must not block daemon exit — after exit it
// reparents to init, while a blocked daemon would orphan alongside it.
this.log.log('shutdown-dispose-failed', {
error: err instanceof Error ? err.message : String(err)
})
}
this.streamDataBatcher.clear()
for (const [, client] of this.clients) {
@@ -580,7 +588,16 @@ export class DaemonServer {
killSessions: request.payload.killSessions === true
})
if (request.payload.killSessions) {
await this.host.dispose()
try {
await this.host.dispose()
} catch (err) {
// Why: the shutdown RPC contract is that the daemon always
// self-terminates; dispose keeps failed owners retryable, and the
// follow-up shutdown() below retries them once more before exit.
this.log.log('shutdown-dispose-failed', {
error: err instanceof Error ? err.message : String(err)
})
}
}
process.nextTick(() => this.shutdown())
return {}