diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 45736e2ec0c..40270a7597a 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -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 } + } + // 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() + }) }) }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 1b1128816e5..3396493e0fd 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -149,7 +149,15 @@ export class DaemonServer { async shutdown(): Promise { 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 {}