From a635ff9a72b540ec9c4193aa3c71206ad058cd9a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:44:45 -0700 Subject: [PATCH] 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. --- src/main/daemon/daemon-server.test.ts | 24 +++++++++++++++++++++++- src/main/daemon/daemon-server.ts | 21 +++++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) 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 {}