diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs index be920ca71..974e86572 100644 --- a/config/scripts/windows-daemon-workspace-close-repro.mjs +++ b/config/scripts/windows-daemon-workspace-close-repro.mjs @@ -30,19 +30,99 @@ function readProtocolVersion() { return Number(match[1]) } +function createStreamSocket(socketPath, token, protocolVersion, clientId, onFailure) { + const socket = connect(socketPath) + let buffer = '' + + return new Promise((resolveStream, rejectStream) => { + let handshakeComplete = false + const rejectHandshake = (error) => { + if (handshakeComplete) { + onFailure(error) + return + } + handshakeComplete = true + clearTimeout(timer) + socket.removeListener('data', onData) + socket.removeListener('error', onError) + socket.removeListener('close', onClose) + socket.destroy() + rejectStream(error) + } + const onError = (error) => rejectHandshake(error) + const onClose = () => rejectHandshake(new Error('Daemon stream socket closed')) + const onData = (chunk) => { + buffer += chunk.toString('utf8') + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const message = JSON.parse(buffer.slice(0, newline)) + if (message.type !== 'hello') { + return + } + if (!message.ok) { + rejectHandshake(new Error(message.error ?? 'Daemon rejected stream hello')) + return + } + handshakeComplete = true + clearTimeout(timer) + socket.removeListener('data', onData) + // Why: drain terminal events even though this lifecycle repro only asserts through RPC. + socket.on('data', () => {}) + resolveStream(socket) + } + const timer = setTimeout( + () => rejectHandshake(new Error('Daemon stream hello timed out')), + requestTimeoutMs + ) + socket.on('error', onError) + socket.on('close', onClose) + socket.on('data', onData) + socket.once('connect', () => { + socket.write( + `${JSON.stringify({ + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'stream' + })}\n` + ) + }) + }) +} + function createRpcClient(socketPath, tokenPath) { const socket = connect(socketPath) + const clientId = randomUUID() + const protocolVersion = readProtocolVersion() + const token = readFileSync(tokenPath, 'utf8').trim() const pending = new Map() let buffer = '' let requestId = 0 + let streamSocket + let connectionError let helloResolve let helloReject + let helloTimer const hello = new Promise((resolveHello, rejectHello) => { - helloResolve = resolveHello - helloReject = rejectHello + helloResolve = () => { + clearTimeout(helloTimer) + resolveHello() + } + helloReject = (error) => { + clearTimeout(helloTimer) + rejectHello(error) + } }) + helloTimer = setTimeout(() => { + helloReject(new Error('Daemon control hello timed out')) + socket.destroy() + }, requestTimeoutMs) const rejectPending = (error) => { + connectionError ??= error helloReject(error) for (const { reject, timer } of pending.values()) { clearTimeout(timer) @@ -82,25 +162,40 @@ function createRpcClient(socketPath, tokenPath) { } }) - const connected = new Promise((resolveConnected, rejectConnected) => { + const socketConnected = new Promise((resolveConnected, rejectConnected) => { socket.once('connect', resolveConnected) socket.once('error', rejectConnected) - }).then(() => { - socket.write( - `${JSON.stringify({ - type: 'hello', - version: readProtocolVersion(), - token: readFileSync(tokenPath, 'utf8').trim(), - clientId: randomUUID(), - role: 'control' - })}\n` + }) + const connected = Promise.all([ + socketConnected.then(() => { + socket.write( + `${JSON.stringify({ + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'control' + })}\n` + ) + }), + hello + ]).then(async () => { + // Why: v24 only admits terminals for the same complete control+stream pair as production. + streamSocket = await createStreamSocket( + socketPath, + token, + protocolVersion, + clientId, + rejectPending ) - return hello }) return { async request(type, payload) { await connected + if (connectionError) { + throw connectionError + } const id = `repro-${++requestId}` return new Promise((resolveRequest, rejectRequest) => { const timer = setTimeout(() => { @@ -112,6 +207,7 @@ function createRpcClient(socketPath, tokenPath) { }) }, close() { + streamSocket?.destroy() socket.destroy() } } diff --git a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md new file mode 100644 index 000000000..104f64099 --- /dev/null +++ b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md @@ -0,0 +1,279 @@ +# Daemon lifecycle retirement for issue #9138 + +## Status + +Implemented and locally validated for PR #9277. + +The earlier full ownership/audit prototype is preserved at: + +- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot` +- commit: `7c915909bd26670b8af36aa683cff85077395dd1` +- GitHub: + +That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup +audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future +legacy enforcement. None of those systems ship in this PR. + +Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was +in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation +alongside v22 and older versions. + +## Inputs and scope decision + +Issue: + +Reviewed design comment by AmethystLiang: + + +The reviewed comment correctly identifies two different problems: + +1. current-generation daemons have no daemon-owned empty lifecycle; +2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act. + +This PR solves only the first problem. The second problem is much larger, adds steady-state +persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement. +Keeping it out makes the user-visible fix small enough to review and benchmark independently. + +The narrow implementation retains these important rules from AmethystLiang's design: + +- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it; +- live sessions always win over cleanup; +- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence; +- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation; +- the daemon, not app startup, makes the atomic empty decision; +- pre-v24 daemons stay reattachable and are not automatically shut down; +- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged. + +The narrow implementation changes one policy from the original comment: it does not use a blanket +30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle +event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime +inactivity heuristics and periodic ownership work. + +## User-visible behavior + +```text +Before + +app disconnects ──> daemon stays forever + ├── live sessions stay (wanted) + └── zero sessions also stay (leak) + +After + +clean app detach ──> daemon atomically checks itself + ├── any live session/work/client ──> stay alive + └── exactly empty ──> exit immediately + +unexpected drop ──> daemon atomically checks itself + ├── live session/work/connection ──> stay alive + └── exactly empty ──> exit immediately + +v23 and older ──> existing reattach behavior; no automatic retirement +``` + +An end user with live terminals should notice no change. An end user who quits with no daemon-backed +terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket, +live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later +app restart launches a fresh daemon instead of reusing an empty process. + +## Protocol and lifecycle design + +### Endpoint identity + +Protocol v24 hello responses include: + +```ts +type DaemonEndpointIdentity = { + pid: number + startedAtMs: number + launchNonce: string +} +``` + +The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes +the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client +sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps +the previous identity-free handshake. + +PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a +write failure terminates the new child and fails launch instead of leaving an untracked daemon. + +### Clean detach + +At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a +v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared +250 ms budget. v23 and older adapters skip it. + +Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is +opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still +receive clean retirement on quit. If startup fallback has already won, the late daemon is not +installed; it instead receives the same bounded retirement attempt, which an adopted live session +will reject. + +The daemon accepts retirement only when, in one event-loop turn: + +- the requesting authenticated client has both control and stream sockets; +- it is the only authenticated client; +- every accepted transport belongs to that client; +- no `createOrAttach` operation is in flight; +- the terminal host has zero sessions. + +When all conditions hold, the daemon synchronously closes the listening server before replying. That +is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then +runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to +the same event-driven empty check when the authenticated sockets close. + +### Initial adoption watchdog + +A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated +client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the +normal launch handoff before the parent could connect; without a bound, a parent crash during that +handoff would orphan the new daemon forever. + +A complete pair permanently cancels this watchdog, and terminal admission requires that complete +pair. Raw and partial transports pause it without extending its original deadline. It is never +rearmed after adoption and is not a terminal inactivity or crash-reconnect timer. + +### Unexpected disconnect + +When the last client that completed both authenticated sockets loses its control connection, the +daemon records an event-driven retirement request with no wall-clock grace. + +- A complete authenticated reconnect cancels the request if existing work or a transport kept the + daemon alive long enough to reconnect. +- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request + before admission can begin. +- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but + cannot erase evidence that the last fully connected app left. +- Replacing a client ID first records the old full connection's loss; completing the replacement + stream cancels that evidence, while an incomplete replacement only blocks retirement. +- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately + rechecks every guard and retires only if it is then exactly empty. + +The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the +token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is +not broadened into destructive or respawn authority. + +### Artifact cleanup + +The daemon removes only artifacts it can claim as its own: + +- token contents must match the daemon's in-memory token; +- PID and launch nonce must match the daemon process and launch nonce; +- cleanup first renames the canonical entry to a unique claim, validates that claim, and never + overwrites or unlinks a replacement installed at the canonical path. + +Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24 +PID/socket artifacts. Legacy cleanup behavior is unchanged. + +## Performance design + +There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or +steady-state persistence write in this PR. + +The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one +two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its +connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection +attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect +bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one +unref'ed startup watchdog, which is canceled permanently on adoption. + +Validation compares current main and the branch for: + +- daemon connect plus two-socket hello latency; +- repeated `listSessions` RPC latency; +- terminal echo/stream throughput through a real socket daemon; +- clean empty-detach latency; +- event-loop delay under repeated RPC/stream work; +- idle CPU/RSS and timer count where observable. + +The acceptance target is no statistically meaningful terminal throughput regression and no new +steady-state disk writes. Results are recorded in the PR body. + +### Local performance regression screen + +The final local host was not quiet enough for publication-grade absolute numbers: load averages were +17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired +same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24 +branch samples; medians across sample medians were: + +| Measure | `main` v23 | branch v24 | +| -------------------- | ---------: | ---------: | +| two-socket connect | 1.38 ms | 1.32 ms | +| `listSessions` RPC | 0.0366 ms | 0.0374 ms | +| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s | + +All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and +event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression +screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work +runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY +byte and no steady-state persistence or polling. + +## Verification and validation + +### Focused unit and integration tests + +- v24 requires valid matching endpoint identity on both sockets. +- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols. +- production launch passes PID path and nonce and writes the exact readiness identity. +- incomplete readiness identity or failed exclusive PID publication kills and rejects the child. +- clean empty detach exits immediately. +- a never-used current adapter connects and retires cleanly on quit. +- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old + deadline. +- a live session, another client, raw transport, or in-flight admission rejects clean retirement. +- a control-only overlapping client blocks but cannot erase the last full-client retirement request. +- a same-client-ID control replacement cannot erase the prior full connection's retirement request. +- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed + request. +- startup fail-open performs bounded empty retirement without installing a late provider. +- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later. +- the synchronous listener fence rejects post-fence connections as retryable. +- an unexpected empty disconnect retires immediately without a runtime inactivity timer. +- a real reconnect cancels pending retirement while live work keeps the daemon available. +- raw and health probes block but cannot erase pending retirement. +- final session exit triggers an immediate guarded retirement check. +- token/PID cleanup preserves malformed, stale, and replacement artifacts. +- authenticated token disappearance performs one coalesced respawn; initial token absence does not. + +### Process/E2E tests + +- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts; +- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts + exit; +- prove a live session rejects retirement and remains reattachable; +- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable; +- never target a production runtime directory or signal a process not created by the fixture. + +### Repository validation + +- Node typecheck; +- oxlint and repository max-lines policy; +- focused daemon, adapter, launcher, restart, and legacy-routing suites; +- full daemon test suite; +- desktop and web production builds; +- `git diff --check` and review of every changed file against `origin/main`; +- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`; +- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication. + +Final local results on macOS arm64 after the event-driven policy revision: + +- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped; +- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned + artifacts retired immediately after its final authenticated client disconnected; +- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed; +- full desktop, web, and native production build passed with existing build warnings; +- three independent post-revision review tracks covering architecture/state machines, + ownership/adoption, and process lifecycle ended clean after actionable findings were fixed. + +The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed +successfully, and repository CI remains responsible for the supported Node/platform matrix. + +## Rollout and rollback + +This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves +v24 as another legacy generation and does not give an older app authority to shut it down. + +If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated +ownership/audit prototype remains recoverable from the archived branch and commit above; it should +return only as a separately reviewed, benchmarked follow-up. diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 25aaebe16..a3d6d9f61 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -68,6 +68,12 @@ describe('DaemonClient', () => { onStreamHello?: (msg: HelloMessage) => void rejectVersion?: boolean suppressHelloResponse?: boolean + omitHelloIdentity?: boolean + helloIdentity?: (role: 'control' | 'stream') => { + pid: number + startedAtMs: number + launchNonce: string + } }): Promise { return new Promise((resolve) => { server = createServer((socket) => { @@ -103,7 +109,19 @@ describe('DaemonClient', () => { socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' })) return } - socket.write(encodeNdjson({ type: 'hello', ok: true })) + socket.write( + encodeNdjson({ + type: 'hello', + ok: true, + ...(!opts?.omitHelloIdentity + ? { + daemonIdentity: opts?.helloIdentity + ? opts.helloIdentity(hello.role) + : { pid: 123, startedAtMs: 456, launchNonce: 'default-launch' } + } + : {}) + }) + ) if (hello.role === 'stream') { opts?.onStreamHello?.(hello) } @@ -136,6 +154,48 @@ describe('DaemonClient', () => { await waitFor(() => hellos.length > 0) }) + it('captures one matching endpoint identity from both authenticated sockets', async () => { + const identity = { pid: 123, startedAtMs: 456, launchNonce: 'launch-a' } + await startMockDaemon({ helloIdentity: () => identity }) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + expect(client.getDaemonIdentity()).toEqual(identity) + }) + + it('rejects a v24 daemon that omits endpoint identity', async () => { + await startMockDaemon({ omitHelloIdentity: true }) + client = new DaemonClient({ socketPath, tokenPath }) + + await expect(client.ensureConnected()).rejects.toThrow('Invalid daemon identity') + }) + + it('allows a legacy v23 daemon to omit endpoint identity', async () => { + await startMockDaemon({ omitHelloIdentity: true }) + client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 23 }) + + await expect(client.ensureConnected()).resolves.toBeUndefined() + expect(client.getDaemonIdentity()).toBeNull() + }) + + it('rejects when control and stream sockets report different daemon identities', async () => { + await startMockDaemon({ + helloIdentity: (role) => ({ + pid: role === 'control' ? 123 : 124, + startedAtMs: 456, + launchNonce: 'launch-a' + }) + }) + + client = new DaemonClient({ socketPath, tokenPath }) + + await expect(client.ensureConnected()).rejects.toThrow( + 'Daemon identity changed during connection' + ) + expect(client.getDaemonIdentity()).toBeNull() + }) + it('removes socket startup listeners after connecting', async () => { await startMockDaemon() @@ -189,6 +249,35 @@ describe('DaemonClient', () => { } }) + it('bounds a waiter on an existing connection attempt and prevents socket resurrection', async () => { + let resolveHello: () => void = () => {} + const helloReceived = new Promise((resolve) => { + resolveHello = resolve + }) + await startMockDaemon({ + suppressHelloResponse: true, + onHello: resolveHello + }) + client = new DaemonClient({ socketPath, tokenPath }) + const ownerAttempt = client.ensureConnected().catch((error: Error) => error) + await helloReceived + + await expect(client.ensureConnectedWithin(25)).rejects.toThrow( + 'Connection attempt wait timed out' + ) + client.disconnect() + await expect(ownerAttempt).resolves.toBeInstanceOf(Error) + await new Promise((resolve) => setTimeout(resolve, 25)) + + const disconnected = client as unknown as { + controlSocket: Socket | null + streamSocket: Socket | null + } + expect(client.isConnected()).toBe(false) + expect(disconnected.controlSocket).toBeNull() + expect(disconnected.streamSocket).toBeNull() + }) + it('removes hello startup listeners after timeout', async () => { vi.useFakeTimers() @@ -200,11 +289,16 @@ describe('DaemonClient', () => { socket.destroy = destroy as unknown as Socket['destroy'] const sendHello = ( client as unknown as { - sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise + sendHello( + socket: Socket, + token: string, + role: 'control' | 'stream', + timeoutMs: number + ): Promise } ).sendHello.bind(client) - const promise = sendHello(socket, 'test-token-123', 'control') + const promise = sendHello(socket, 'test-token-123', 'control', 5000) const rejection = expect(promise).rejects.toThrow('Hello response timed out') await vi.advanceTimersByTimeAsync(5000) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 8b6c1b29d..d0dd24534 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -4,11 +4,23 @@ import { readFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' -import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types' -import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types' +import { + CLEAN_DISCONNECT_PROTOCOL_VERSION, + PROTOCOL_VERSION, + NOTIFY_PREFIX, + DaemonProtocolError +} from './types' +import type { + DaemonEndpointIdentity, + HelloMessage, + HelloResponse, + RpcResponse, + DaemonEvent +} from './types' import { addNodePtyRecoveryHint } from './node-pty-error-hints' const CONNECT_TIMEOUT_MS = 5000 +const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 const REQUEST_TIMEOUT_MS = 30000 export type DaemonClientOptions = { @@ -42,6 +54,9 @@ export class DaemonClient { // all call ensureConnected(). Without a lock, each starts a separate // connection attempt, overwriting sockets and triggering "Connection lost". private connectingPromise: Promise | null = null + private connectionAttemptGeneration = 0 + private daemonIdentity: DaemonEndpointIdentity | null = null + private observedAuthenticatedDisconnect = false private pendingRequests = new Map() private eventListeners: ((event: unknown) => void)[] = [] @@ -59,15 +74,38 @@ export class DaemonClient { return this.connected } + getDaemonIdentity(): DaemonEndpointIdentity | null { + return this.daemonIdentity ? { ...this.daemonIdentity } : null + } + + hasObservedAuthenticatedDisconnect(): boolean { + return this.observedAuthenticatedDisconnect + } + async ensureConnected(): Promise { + return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false) + } + + async ensureConnectedWithin(timeoutMs: number): Promise { + return this.ensureConnectedWithTimeout(timeoutMs, true) + } + + private async ensureConnectedWithTimeout( + timeoutMs: number, + sharedBudget: boolean + ): Promise { if (this.connected) { return } if (this.connectingPromise) { - return this.connectingPromise + // Why: a normal connection may legitimately consume one timeout for each + // socket and hello; bounded teardown calls instead keep their one shared budget. + const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECTION_ATTEMPT_WAIT_MS + return this.waitForConnectionAttempt(this.connectingPromise, waiterTimeoutMs) } - this.connectingPromise = this.doConnect() + const attemptGeneration = this.connectionAttemptGeneration + this.connectingPromise = this.doConnect(timeoutMs, attemptGeneration, sharedBudget) try { await this.connectingPromise } finally { @@ -75,8 +113,15 @@ export class DaemonClient { } } - private async doConnect(): Promise { + private async doConnect( + timeoutMs: number, + attemptGeneration: number, + sharedBudget: boolean + ): Promise { const token = readFileSync(this.tokenPath, 'utf-8').trim() + const deadlineMs = Date.now() + timeoutMs + const remainingMs = (): number => + sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs const pendingListenerCleanups: (() => void)[] = [] const cleanupPendingListeners = (): void => { for (const cleanup of pendingListenerCleanups.splice(0)) { @@ -86,15 +131,32 @@ export class DaemonClient { try { // Sequential: control first, then stream - this.controlSocket = await this.connectSocket() - await this.sendHello(this.controlSocket, token, 'control') + const pendingControlSocket = await this.connectSocket(remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket) + this.controlSocket = pendingControlSocket + const controlIdentity = await this.sendHello( + this.controlSocket, + token, + 'control', + remainingMs() + ) + this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket) pendingListenerCleanups.push(this.setupControlParser(this.controlSocket)) - this.streamSocket = await this.connectSocket() - await this.sendHello(this.streamSocket, token, 'stream') + const pendingStreamSocket = await this.connectSocket(remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket) + this.streamSocket = pendingStreamSocket + const streamIdentity = await this.sendHello(this.streamSocket, token, 'stream', remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket) + if (!sameDaemonIdentity(controlIdentity, streamIdentity)) { + throw new DaemonProtocolError('Daemon identity changed during connection') + } pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket)) + this.assertConnectionAttemptCurrent(attemptGeneration) this.connected = true + this.observedAuthenticatedDisconnect = false + this.daemonIdentity = controlIdentity this.disconnectArmed = true this.connectionGeneration++ @@ -120,12 +182,17 @@ export class DaemonClient { this.controlSocket = null this.streamSocket = null this.connected = false + this.daemonIdentity = null this.disconnectArmed = false throw error } } - async request(type: string, payload: unknown): Promise { + async request( + type: string, + payload: unknown, + timeoutMs = REQUEST_TIMEOUT_MS + ): Promise { if (!this.connected || !this.controlSocket) { throw new DaemonProtocolError('Not connected') } @@ -136,8 +203,8 @@ export class DaemonClient { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRequests.delete(id) - reject(new DaemonProtocolError(`Request ${type} timed out after ${REQUEST_TIMEOUT_MS}ms`)) - }, REQUEST_TIMEOUT_MS) + reject(new DaemonProtocolError(`Request ${type} timed out after ${timeoutMs}ms`)) + }, timeoutMs) this.pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, @@ -180,7 +247,9 @@ export class DaemonClient { } disconnect(): void { + this.connectionAttemptGeneration++ this.connected = false + this.daemonIdentity = null this.disconnectArmed = false this.cleanupActiveSocketListeners() @@ -196,7 +265,7 @@ export class DaemonClient { this.streamSocket = null } - private connectSocket(): Promise { + private connectSocket(timeoutMs: number): Promise { return new Promise((resolve, reject) => { const socket = connect(this.socketPath) const cleanup = (): void => { @@ -216,14 +285,45 @@ export class DaemonClient { cleanup() socket.destroy() reject(new DaemonProtocolError('Connection timed out')) - }, CONNECT_TIMEOUT_MS) + }, timeoutMs) socket.on('connect', onConnect) socket.on('error', onError) }) } - private sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise { + private waitForConnectionAttempt(attempt: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new DaemonProtocolError('Connection attempt wait timed out')) + }, timeoutMs) + attempt.then( + () => { + clearTimeout(timer) + resolve() + }, + (error) => { + clearTimeout(timer) + reject(error) + } + ) + }) + } + + private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Socket): void { + if (attemptGeneration === this.connectionAttemptGeneration) { + return + } + socket?.destroy() + throw new DaemonProtocolError('Disconnected') + } + + private sendHello( + socket: Socket, + token: string, + role: 'control' | 'stream', + timeoutMs: number + ): Promise { return new Promise((resolve, reject) => { const hello: HelloMessage = { type: 'hello', @@ -245,7 +345,7 @@ export class DaemonClient { socket.removeListener('error', onError) socket.removeListener('close', onClose) } - const finish = (error?: Error): void => { + const finish = (error?: Error, identity: DaemonEndpointIdentity | null = null): void => { if (settled) { return } @@ -255,7 +355,7 @@ export class DaemonClient { reject(error) return } - resolve() + resolve(identity) } // Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes. // Decoding each Buffer independently would permanently inject U+FFFD. @@ -271,7 +371,15 @@ export class DaemonClient { try { const response = JSON.parse(line) as HelloResponse if (response.ok) { - finish() + const identity = parseDaemonEndpointIdentity(response.daemonIdentity) + if ( + (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION && identity === null) || + (response.daemonIdentity !== undefined && identity === null) + ) { + finish(new DaemonProtocolError('Invalid daemon identity')) + return + } + finish(undefined, identity) } else { finish( new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected')) @@ -290,7 +398,7 @@ export class DaemonClient { // without a handshake timeout, startup waits forever on ensureConnected(). finish(new DaemonProtocolError('Hello response timed out')) socket.destroy() - }, CONNECT_TIMEOUT_MS) + }, timeoutMs) socket.on('data', onData) socket.on('error', onError) socket.on('close', onClose) @@ -352,7 +460,12 @@ export class DaemonClient { return } this.disconnectArmed = false + this.connectionAttemptGeneration++ + if (this.daemonIdentity) { + this.observedAuthenticatedDisconnect = true + } this.connected = false + this.daemonIdentity = null this.cleanupActiveSocketListeners() for (const [id, pending] of this.pendingRequests) { @@ -377,3 +490,40 @@ export class DaemonClient { cleanup?.() } } + +function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | null { + if (!value || typeof value !== 'object') { + return null + } + const identity = value as { pid?: unknown; startedAtMs?: unknown; launchNonce?: unknown } + if ( + !Number.isSafeInteger(identity.pid) || + (identity.pid as number) <= 0 || + typeof identity.startedAtMs !== 'number' || + !Number.isFinite(identity.startedAtMs) || + identity.startedAtMs <= 0 || + typeof identity.launchNonce !== 'string' || + identity.launchNonce.length === 0 + ) { + return null + } + return { + pid: identity.pid as number, + startedAtMs: identity.startedAtMs, + launchNonce: identity.launchNonce + } +} + +function sameDaemonIdentity( + left: DaemonEndpointIdentity | null, + right: DaemonEndpointIdentity | null +): boolean { + return ( + (left === null && right === null) || + (left !== null && + right !== null && + left.pid === right.pid && + left.startedAtMs === right.startedAtMs && + left.launchNonce === right.launchNonce) + ) +} diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index e24f1c8b3..deedb82a2 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -51,6 +51,49 @@ describe('daemon-entry parseArgs', () => { }) }) + it('parses the internal PID-record ownership pair', () => { + expect( + parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--pid-record', + '/tmp/t.pid', + '--launch-nonce', + 'launch-a' + ]) + ).toEqual({ + socketPath: '/tmp/t.sock', + tokenPath: '/tmp/t.token', + pidPath: '/tmp/t.pid', + launchNonce: 'launch-a' + }) + }) + + it('rejects either PID-record ownership argument without its pair', () => { + expect(() => + parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--pid-record', + '/tmp/t.pid' + ]) + ).toThrow('provided together') + expect(() => + parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--launch-nonce', + 'launch-a' + ]) + ).toThrow('provided together') + }) + it('still requires --socket and --token when --log-file is given', () => { expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:') }) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 4d5207276..12c5ad8dd 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -17,6 +17,8 @@ import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell' export type ParsedDaemonArgs = { socketPath: string tokenPath: string + pidPath?: string + launchNonce?: string /** Optional — absent for adopted old daemons and tests, which log nothing. */ logFilePath?: string } @@ -25,6 +27,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { let socketPath = '' let tokenPath = '' let logFilePath = '' + let pidPath = '' + let launchNonce = '' for (let i = 0; i < argv.length; i++) { if (argv[i] === '--socket' && argv[i + 1]) { @@ -36,6 +40,12 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { } else if (argv[i] === '--log-file' && argv[i + 1]) { logFilePath = argv[i + 1] i++ + } else if (argv[i] === '--pid-record' && argv[i + 1]) { + pidPath = argv[i + 1] + i++ + } else if (argv[i] === '--launch-nonce' && argv[i + 1]) { + launchNonce = argv[i + 1] + i++ } } @@ -43,7 +53,16 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { throw new Error('Usage: daemon-entry --socket --token [--log-file ]') } - return logFilePath ? { socketPath, tokenPath, logFilePath } : { socketPath, tokenPath } + if ((pidPath && !launchNonce) || (!pidPath && launchNonce)) { + throw new Error('Daemon PID record path and launch nonce must be provided together') + } + + return { + socketPath, + tokenPath, + ...(pidPath ? { pidPath, launchNonce } : {}), + ...(logFilePath ? { logFilePath } : {}) + } } async function main(): Promise { @@ -54,7 +73,10 @@ async function main(): Promise { // an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only. process.stderr.on('error', () => {}) - const { socketPath, tokenPath, logFilePath } = parseArgs(process.argv.slice(2)) + const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs( + process.argv.slice(2) + ) + const startedAtMs = Date.now() - process.uptime() * 1000 // Fail-open: a broken log path must never block daemon startup. const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog() daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath }) @@ -126,16 +148,25 @@ async function main(): Promise { daemon = await startDaemon({ socketPath, tokenPath, + ...(pidPath ? { pidPath } : {}), + ...(launchNonce ? { launchNonce } : {}), + ...(pidPath ? { startedAtMs } : {}), log: daemonLog, preparePtySpawn: prepareMacosTccLoginShell, - spawnSubprocess: (opts) => createPtySubprocess(opts) + spawnSubprocess: (opts) => createPtySubprocess(opts), + onIdleShutdown: () => { + shuttingDown = true + daemonLog.log('shutdown', { reason: 'idle' }) + daemonLog.close() + process.exit(0) + } }) // Signal readiness to parent via IPC (if available) if (process.send) { // Why: Windows has no cheap OS query for a child's start time, so the // daemon self-reports it here for the pid file's pid-recycling guard. - process.send({ type: 'ready', startedAtMs: Date.now() - process.uptime() * 1000 }) + process.send({ type: 'ready', startedAtMs }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 4fbb3047d..028fb8f0c 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,9 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBeGreaterThan(19) + expect(PROTOCOL_VERSION).toBe(24) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) }) }) diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts new file mode 100644 index 000000000..4e288e4db --- /dev/null +++ b/src/main/daemon/daemon-hello-protocol.ts @@ -0,0 +1,20 @@ +export type HelloMessage = { + type: 'hello' + version: number + token: string + clientId: string + role: 'control' | 'stream' +} + +export type DaemonEndpointIdentity = { + pid: number + startedAtMs: number + launchNonce: string +} + +export type HelloResponse = { + type: 'hello' + ok: boolean + error?: string + daemonIdentity?: DaemonEndpointIdentity +} diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts new file mode 100644 index 000000000..fa12ba500 --- /dev/null +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -0,0 +1,728 @@ +import { EventEmitter } from 'node:events' +import { connect, type Socket } from 'node:net' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { PROTOCOL_VERSION } from './types' +import { + getDaemonPidPath, + getDaemonSocketPath, + serializeDaemonPidFile, + unlinkOwnedDaemonPidFile +} from './daemon-spawner' +import type { SubprocessHandle } from './session' + +type ManualTimer = { + callback: () => void + dueAt: number + cancelled: boolean +} + +class ManualIdleClock { + private nowMs = 0 + private timers = new Set() + + setTimeout(callback: () => void, delayMs: number): ManualTimer { + const timer = { callback, dueAt: this.nowMs + delayMs, cancelled: false } + this.timers.add(timer) + return timer + } + + clearTimeout(handle: unknown): void { + const timer = handle as ManualTimer + timer.cancelled = true + this.timers.delete(timer) + } + + now(): number { + return this.nowMs + } + + advanceBy(ms: number): void { + this.nowMs += ms + for (const timer of [...this.timers].sort((a, b) => a.dueAt - b.dueAt)) { + if (timer.cancelled || timer.dueAt > this.nowMs) { + continue + } + this.timers.delete(timer) + timer.callback() + } + } + + get pendingCount(): number { + return this.timers.size + } +} + +function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } { + let onExit: ((code: number) => void) | null = null + return { + pid: 9345, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + forceKill: vi.fn(), + signal: vi.fn(), + onData: vi.fn(), + onExit(callback) { + onExit = callback + }, + dispose: vi.fn(), + exit(code) { + onExit?.(code) + } + } +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000 + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for daemon idle state') + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +async function requestOnRawSocket( + socket: Socket, + request: { id: string; type: string; payload: unknown } +): Promise<{ error?: string }> { + return new Promise((resolve, reject) => { + let buffer = '' + const cleanup = (): void => { + clearTimeout(timeout) + socket.off('data', onData) + } + const onData = (chunk: Buffer): void => { + buffer += chunk.toString('utf8') + for (;;) { + const newlineIndex = buffer.indexOf('\n') + if (newlineIndex === -1) { + return + } + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) + const message = JSON.parse(line) as { id?: string; error?: string } + if (message.id === request.id) { + cleanup() + resolve(message) + return + } + } + } + const timeout = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for raw response ${request.id}`)) + }, 2_000) + socket.on('data', onData) + socket.write(`${JSON.stringify(request)}\n`) + }) +} + +describe('current daemon lifecycle retirement', () => { + let dir: string + let socketPath: string + let tokenPath: string + let pidPath: string + let clock: ManualIdleClock + let server: DaemonServer | null + let subprocess: ReturnType + let onIdleShutdown: ReturnType void>> + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-idle-shutdown-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + pidPath = getDaemonPidPath(dir) + clock = new ManualIdleClock() + subprocess = createMockSubprocess() + onIdleShutdown = vi.fn<() => void>() + server = null + }) + + afterEach(async () => { + await server?.shutdown().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + }) + + async function startServer( + options: { + launchNonce?: string + protocolVersion?: number + } = {} + ): Promise { + server = new DaemonServer({ + socketPath, + tokenPath, + ...(options.launchNonce ? { pidPath, launchNonce: options.launchNonce } : {}), + ...(options.protocolVersion !== undefined + ? { protocolVersion: options.protocolVersion } + : {}), + initialAdoptionTestConfig: { timeoutMs: 100, clock }, + onIdleShutdown, + spawnSubprocess: () => subprocess + }) + await server.start() + } + + it('retires immediately after an unexpected empty disconnect and removes owned artifacts', async () => { + const launchNonce = 'launch-a' + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce }) + ) + await startServer({ launchNonce }) + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + client.disconnect() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + + expect(clock.pendingCount).toBe(0) + expect(existsSync(tokenPath)).toBe(false) + expect(existsSync(pidPath)).toBe(false) + if (process.platform !== 'win32') { + expect(existsSync(socketPath)).toBe(false) + } + }) + + it('retires a fresh daemon that is never adopted by a full client pair', async () => { + await startServer() + + expect(clock.pendingCount).toBe(1) + clock.advanceBy(100) + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('retires immediately after an authenticated clean disconnect proves it is empty', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: true + }) + client.disconnect() + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('keeps resources alive until the shutdownIfIdle reply write flushes', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as { + clients: Map + host: { dispose: () => Promise } + } + const controlSocket = [...daemon.clients.values()][0].controlSocket + const originalWrite = controlSocket.write.bind(controlSocket) + let replyFlushed: (() => void) | undefined + vi.spyOn(controlSocket, 'write').mockImplementation((( + chunk: string | Uint8Array, + ...args: unknown[] + ) => { + replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined + return originalWrite(chunk) + }) as unknown as Socket['write']) + const dispose = vi.spyOn(daemon.host, 'dispose') + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: true }) + expect(dispose).not.toHaveBeenCalled() + expect(onIdleShutdown).not.toHaveBeenCalled() + + replyFlushed?.() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(dispose).toHaveBeenCalledOnce() + client.disconnect() + }) + + it('finishes shutdownIfIdle when the peer closes before its reply callback', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as { + clients: Map + host: { dispose: () => Promise } + } + const controlSocket = [...daemon.clients.values()][0].controlSocket + const originalWrite = controlSocket.write.bind(controlSocket) + vi.spyOn(controlSocket, 'write').mockImplementation(((chunk: string | Uint8Array) => + originalWrite(chunk)) as unknown as Socket['write']) + const dispose = vi.spyOn(daemon.host, 'dispose') + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: true }) + expect(dispose).not.toHaveBeenCalled() + client.disconnect() + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('connects and retires a never-used adapter during clean disconnect', async () => { + await startServer() + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + + await adapter.disconnectOnly() + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('preserves a live session after clean detach and retires when that session exits', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + await client.request('createOrAttach', { sessionId: 'preserved', cols: 80, rows: 24 }) + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: false + }) + client.disconnect() + expect(onIdleShutdown).not.toHaveBeenCalled() + subprocess.exit(0) + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('rejects clean retirement while create or attach is in flight', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as { createOrAttachInFlight: number } + daemon.createOrAttachInFlight = 1 + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: false + }) + + expect(onIdleShutdown).not.toHaveBeenCalled() + client.disconnect() + }) + + it('rejects clean retirement when an unknown transport is connected', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const rawSocket = connect(socketPath) + await new Promise((resolve) => rawSocket.once('connect', resolve)) + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: false + }) + rawSocket.destroy() + expect(onIdleShutdown).not.toHaveBeenCalled() + client.disconnect() + }) + + it('rejects clean retirement while another authenticated client is connected', async () => { + await startServer() + const first = new DaemonClient({ socketPath, tokenPath }) + const second = new DaemonClient({ socketPath, tokenPath }) + await Promise.all([first.ensureConnected(), second.ensureConnected()]) + + await expect(first.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: false + }) + + expect(onIdleShutdown).not.toHaveBeenCalled() + first.disconnect() + second.disconnect() + }) + + it("preserves another client's live session when failed adoption disconnects", async () => { + await startServer() + const adoptingAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + const liveOwner = new DaemonClient({ socketPath, tokenPath }) + await Promise.all([adoptingAdapter.establishLifecycleLease(), liveOwner.ensureConnected()]) + await liveOwner.request('createOrAttach', { + sessionId: 'owned-by-second-client', + cols: 80, + rows: 24 + }) + + await adoptingAdapter.disconnectOnly() + + expect(onIdleShutdown).not.toHaveBeenCalled() + await expect(liveOwner.request('listSessions', undefined)).resolves.toMatchObject({ + sessions: [expect.objectContaining({ sessionId: 'owned-by-second-client', isAlive: true })] + }) + subprocess.exit(0) + liveOwner.disconnect() + }) + + it('lets an overlapping raw socket block but not erase empty retirement', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const rawSocket = connect(socketPath) + await new Promise((resolve) => rawSocket.once('connect', resolve)) + client.disconnect() + + expect(onIdleShutdown).not.toHaveBeenCalled() + + rawSocket.destroy() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('retires after the last authenticated client disconnects with no sessions', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + expect(clock.pendingCount).toBe(0) + + client.disconnect() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('lets a complete reconnect cancel retirement while a live session blocks it', async () => { + await startServer() + const first = new DaemonClient({ socketPath, tokenPath }) + await first.ensureConnected() + await first.request('createOrAttach', { sessionId: 'reconnected', cols: 80, rows: 24 }) + first.disconnect() + const daemon = server as unknown as { retirementRequested: boolean } + await waitFor(() => daemon.retirementRequested) + expect(onIdleShutdown).not.toHaveBeenCalled() + + const second = new DaemonClient({ socketPath, tokenPath }) + await second.ensureConnected() + await waitFor(() => !daemon.retirementRequested) + subprocess.exit(0) + expect(onIdleShutdown).not.toHaveBeenCalled() + + second.disconnect() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('cancels the initial-adoption timeout before the adapter creates its first terminal', async () => { + await startServer() + await waitFor(() => clock.pendingCount === 1) + clock.advanceBy(50) + + const adopted = new DaemonPtyAdapter({ socketPath, tokenPath }) + await adopted.establishLifecycleLease() + expect(clock.pendingCount).toBe(0) + + clock.advanceBy(100) + expect(onIdleShutdown).not.toHaveBeenCalled() + await expect( + adopted.spawn({ + sessionId: 'first-after-adoption', + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ id: 'first-after-adoption' }) + subprocess.exit(0) + adopted.dispose() + }) + + it('does not let repeated authenticated control probes extend the startup deadline', async () => { + await startServer() + const healthControl = connect(socketPath) + await new Promise((resolve) => healthControl.once('connect', resolve)) + healthControl.write( + `${JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: 'startup-health-control', + role: 'control' + })}\n` + ) + const daemon = server as unknown as { clients: Map } + await waitFor(() => daemon.clients.has('startup-health-control')) + clock.advanceBy(1_000) + healthControl.destroy() + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('does not let a control-only create cancel the initial-adoption timeout', async () => { + await startServer() + const control = connect(socketPath) + await new Promise((resolve) => control.once('connect', resolve)) + control.write( + `${JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: 'startup-control-create', + role: 'control' + })}\n` + ) + const daemon = server as unknown as { + clients: Map + retirementRequested: boolean + } + await waitFor(() => daemon.clients.has('startup-control-create')) + const response = await requestOnRawSocket(control, { + id: 'control-only-create', + type: 'createOrAttach', + payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } + }) + expect(response.error).toContain('connection is incomplete') + expect(daemon.retirementRequested).toBe(false) + expect(clock.pendingCount).toBe(0) + + clock.advanceBy(1_000) + expect(onIdleShutdown).not.toHaveBeenCalled() + + control.destroy() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('keeps empty-retirement intent when a control-only client overlaps the last app', async () => { + await startServer() + const paired = new DaemonClient({ socketPath, tokenPath }) + await paired.ensureConnected() + const incomplete = connect(socketPath) + await new Promise((resolve) => incomplete.once('connect', resolve)) + incomplete.write( + `${JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: 'control-only-overlap', + role: 'control' + })}\n` + ) + const daemon = server as unknown as { + clients: Map + retirementRequested: boolean + } + await waitFor(() => daemon.clients.has('control-only-overlap')) + + paired.disconnect() + await waitFor(() => daemon.retirementRequested) + expect(clock.pendingCount).toBe(0) + const response = await requestOnRawSocket(incomplete, { + id: 'overlap-control-create', + type: 'createOrAttach', + payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } + }) + expect(response.error).toContain('connection is incomplete') + expect(daemon.retirementRequested).toBe(true) + + incomplete.destroy() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('keeps retirement intent when a same-client replacement never completes its stream', async () => { + await startServer() + const paired = new DaemonClient({ socketPath, tokenPath }) + await paired.ensureConnected() + const clientId = (paired as unknown as { clientId: string }).clientId + const replacementControl = connect(socketPath) + await new Promise((resolve) => replacementControl.once('connect', resolve)) + replacementControl.write( + `${JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId, + role: 'control' + })}\n` + ) + const daemon = server as unknown as { + retirementRequested: boolean + } + await waitFor(() => daemon.retirementRequested) + expect(clock.pendingCount).toBe(0) + + replacementControl.destroy() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('keeps a live session after clients disconnect, then retires on its exit', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + await client.request('createOrAttach', { sessionId: 'live', cols: 80, rows: 24 }) + client.disconnect() + const daemon = server as unknown as { retirementRequested: boolean } + await waitFor(() => daemon.retirementRequested) + + expect(onIdleShutdown).not.toHaveBeenCalled() + expect(clock.pendingCount).toBe(0) + + subprocess.exit(0) + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('uses the direct-construction protocol fixture version for hello compatibility', async () => { + await startServer({ protocolVersion: 22 }) + const client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 22 }) + + await expect(client.ensureConnected()).resolves.toBeUndefined() + client.disconnect() + }) + + it('requests clean retirement only for protocol v24 and newer adapters', async () => { + await startServer() + const current = new DaemonPtyAdapter({ socketPath, tokenPath }) + await current.listProcesses() + const currentClient = ( + current as unknown as { + client: DaemonClient + } + ).client + const currentRequest = vi.spyOn(currentClient, 'request') + + await current.disconnectOnly() + + expect(currentRequest).toHaveBeenCalledOnce() + expect(currentRequest.mock.calls[0]?.slice(0, 2)).toEqual(['shutdownIfIdle', undefined]) + const timeoutMs = currentRequest.mock.calls[0]?.[2] + // Why: connection and RPC share a wall-clock budget, so elapsed setup time is expected. + expect(timeoutMs).toEqual(expect.any(Number)) + expect(timeoutMs).toBeGreaterThan(0) + expect(timeoutMs).toBeLessThanOrEqual(250) + }) + + it('does not send the v24 clean-disconnect RPC to a legacy daemon', async () => { + await startServer({ protocolVersion: 23 }) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 }) + await legacy.listProcesses() + const legacyClient = ( + legacy as unknown as { + client: DaemonClient + } + ).client + const legacyRequest = vi.spyOn(legacyClient, 'request') + + await legacy.disconnectOnly() + + expect(legacyRequest.mock.calls.map(([type]) => type)).not.toContain('shutdownIfIdle') + }) + + it('rejects create or attach once the idle admission fence is pending', async () => { + await startServer() + const daemon = server as unknown as { + idleShutdownState: string + routeRequest(clientId: string, request: unknown): Promise + } + daemon.idleShutdownState = 'idle-shutdown-pending' + + await expect( + daemon.routeRequest('late-client', { + id: 'late-create', + type: 'createOrAttach', + payload: { sessionId: 'late', cols: 80, rows: 24 } + }) + ).rejects.toThrow('temporarily unavailable; reconnect') + }) + + it('aborts the pending shutdown when create or attach started before the fence', async () => { + await startServer() + const daemon = server as unknown as { + createOrAttachInFlight: number + idleShutdownState: string + beginIdleShutdown(): void + } + daemon.createOrAttachInFlight = 1 + + daemon.beginIdleShutdown() + + expect(daemon.idleShutdownState).toBe('running') + expect(onIdleShutdown).not.toHaveBeenCalled() + }) + + it('explicitly marks a post-fence accepted transport as retryable', async () => { + await startServer() + const daemon = server as unknown as { + idleShutdownState: string + handleConnection(socket: Socket): void + } + daemon.idleShutdownState = 'idle-shutdown-pending' + const socket = new EventEmitter() as Socket + socket.end = vi.fn() as unknown as Socket['end'] + + daemon.handleConnection(socket) + + const payload = vi.mocked(socket.end).mock.calls[0]?.[0] + expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true }) + socket.emit('close') + }) + + it('preserves a replacement PID record during otherwise successful idle cleanup', async () => { + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce: 'replacement' }) + ) + await startServer({ launchNonce: 'mine' }) + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + await client.request('shutdownIfIdle', undefined) + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({ + pid: process.pid, + launchNonce: 'replacement' + }) + }) + + it('preserves a token file replaced before idle cleanup', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + writeFileSync(tokenPath, 'replacement-token') + + await client.request('shutdownIfIdle', undefined) + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + + expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token') + }) +}) + +describe('daemon PID record ownership cleanup', () => { + let dir: string + let pidPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-pid-ownership-')) + pidPath = join(dir, 'daemon.pid') + }) + + afterEach(() => rmSync(dir, { recursive: true, force: true })) + + it('unlinks only an exact PID and launch nonce match', () => { + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: 123, startedAtMs: null, launchNonce: 'mine' }) + ) + + expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(true) + expect(existsSync(pidPath)).toBe(false) + }) + + it.each([ + ['malformed', '{'], + ['stale PID', serializeDaemonPidFile({ pid: 456, startedAtMs: null, launchNonce: 'mine' })], + [ + 'replacement nonce', + serializeDaemonPidFile({ pid: 123, startedAtMs: null, launchNonce: 'new' }) + ] + ])('preserves a %s record', (_label, contents) => { + writeFileSync(pidPath, contents) + + expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(false) + expect(readFileSync(pidPath, 'utf8')).toBe(contents) + }) + + it('leaves a missing record missing', () => { + expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(false) + expect(existsSync(pidPath)).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 89493f675..9bc78a4da 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -38,11 +38,17 @@ const { killStaleDaemonMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, + unlinkOwnedDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, + adoptionLeaseReleases, + lifecycleLeaseErrors, + disconnectOnlyErrors, + routerSubscriptionError, adapterInstances, defaultListSessionsSessions, + listProcessesControl, getLocalPtyProviderMock, localFallbackProvider, setLocalPtyProviderMock, @@ -98,6 +104,7 @@ const { const parseDaemonPidFileMock = vi.fn( (): { pid: number; startedAtMs: number | null } | null => null ) + const unlinkOwnedDaemonPidFileMock = vi.fn(() => true) const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { return { @@ -115,12 +122,19 @@ const { tokenPath: string mode?: 'degraded-new-pty-fallback' }>)[] = [] + const adoptionLeaseReleases: ReturnType[] = [] + const lifecycleLeaseErrors: Error[] = [] + const disconnectOnlyErrors: Error[] = [] + const routerSubscriptionError: { current: Error | null } = { current: null } // Same for DaemonPtyAdapter. The test asserts the replacement adapter is a // fresh instance whose respawn closure targets the *original* spawner. const adapterInstances: MockAdapter[] = [] // Why: adapters are constructed inside initDaemonPtyProvider, so tests that // need listSessions to report live sessions set this before calling init. const defaultListSessionsSessions: { sessionId: string }[] = [] + const listProcessesControl: { + current: null | (() => Promise<{ sessionId: string }[]>) + } = { current: null } const localFallbackProvider = { routesFreshSpawnsToLocalProvider: undefined, @@ -171,11 +185,17 @@ const { killStaleDaemonMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, + unlinkOwnedDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, + adoptionLeaseReleases, + lifecycleLeaseErrors, + disconnectOnlyErrors, + routerSubscriptionError, adapterInstances, defaultListSessionsSessions, + listProcessesControl, getLocalPtyProviderMock, localFallbackProvider, setLocalPtyProviderMock, @@ -205,6 +225,7 @@ type MockAdapter = { fanoutSyntheticExits: ReturnType listProcesses: ReturnType listSessions: ReturnType + establishLifecycleLease: ReturnType shutdown: ReturnType dispose: ReturnType disconnectOnly: ReturnType @@ -261,7 +282,11 @@ vi.mock('./daemon-spawner', () => ({ readonly shutdown: ReturnType readonly getHandle: ReturnType private socketCounter: number - private handle: { mode?: 'degraded-new-pty-fallback'; shutdown: () => Promise } | null + private handle: { + mode?: 'degraded-new-pty-fallback' + releaseAdoptionLease?: () => void + shutdown: () => Promise + } | null constructor(opts: { runtimeDir: string; launcher: unknown }) { this.launcher = opts.launcher this.socketCounter = 0 @@ -273,7 +298,9 @@ vi.mock('./daemon-spawner', () => ({ const override = ensureRunningOverrides.shift() if (override) { const result = await override() - this.handle = { shutdown: vi.fn(async () => {}) } + const releaseAdoptionLease = vi.fn() + adoptionLeaseReleases.push(releaseAdoptionLease) + this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) } if (result.mode) { this.handle.mode = result.mode } @@ -283,7 +310,9 @@ vi.mock('./daemon-spawner', () => ({ } } this.socketCounter += 1 - this.handle = { shutdown: vi.fn(async () => {}) } + const releaseAdoptionLease = vi.fn() + adoptionLeaseReleases.push(releaseAdoptionLease) + this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) } return { socketPath: `/fake/socket-${this.socketCounter}`, tokenPath: `/fake/token-${this.socketCounter}` @@ -301,7 +330,8 @@ vi.mock('./daemon-spawner', () => ({ `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.token`, getDaemonPidPath: (_dir: string, version?: number) => `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`, - serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj) + serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj), + unlinkOwnedDaemonPidFile: unlinkOwnedDaemonPidFileMock })) vi.mock('./daemon-pty-adapter', () => ({ @@ -312,6 +342,7 @@ vi.mock('./daemon-pty-adapter', () => ({ readonly fanoutSyntheticExits: ReturnType readonly listProcesses: ReturnType readonly listSessions: ReturnType + readonly establishLifecycleLease: ReturnType readonly shutdown: ReturnType readonly dispose: ReturnType readonly disconnectOnly: ReturnType @@ -326,12 +357,32 @@ vi.mock('./daemon-pty-adapter', () => ({ this.fanoutSyntheticExits = vi.fn(() => { this.callOrder.push('fanoutSyntheticExits') }) - this.listProcesses = vi.fn(async () => []) + this.listProcesses = vi.fn(async () => + listProcessesControl.current ? listProcessesControl.current() : [] + ) this.listSessions = vi.fn(async () => [...defaultListSessionsSessions]) + const lifecycleLeaseError = lifecycleLeaseErrors.shift() + this.establishLifecycleLease = vi.fn(async () => { + if (lifecycleLeaseError) { + throw lifecycleLeaseError + } + }) this.shutdown = vi.fn(async () => {}) this.dispose = vi.fn() - this.disconnectOnly = vi.fn(async () => {}) - this.onData = vi.fn(() => () => {}) + const disconnectOnlyError = disconnectOnlyErrors.shift() + this.disconnectOnly = vi.fn(async () => { + if (disconnectOnlyError) { + throw disconnectOnlyError + } + }) + this.onData = vi.fn(() => { + if (routerSubscriptionError.current) { + const error = routerSubscriptionError.current + routerSubscriptionError.current = null + throw error + } + return () => {} + }) this.onExit = vi.fn(() => () => {}) adapterInstances.push(this as unknown as MockAdapter) } @@ -349,8 +400,13 @@ async function importFresh() { vi.resetModules() spawnerInstances.length = 0 ensureRunningOverrides.length = 0 + adoptionLeaseReleases.length = 0 + lifecycleLeaseErrors.length = 0 + disconnectOnlyErrors.length = 0 + routerSubscriptionError.current = null adapterInstances.length = 0 defaultListSessionsSessions.length = 0 + listProcessesControl.current = null getLocalPtyProviderMock.mockClear() localFallbackProvider.spawn.mockClear() localFallbackProvider.write.mockClear() @@ -374,7 +430,14 @@ async function importFresh() { forkMock.mockReset() isPackagedMock.mockReset() isPackagedMock.mockReturnValue(false) - daemonClientMock.mockClear() + daemonClientMock.mockReset() + daemonClientMock.mockImplementation(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + } + }) probeSocketExistsMock.mockClear() writeFileSyncMock.mockClear() readFileSyncMock.mockReset() @@ -384,6 +447,8 @@ async function importFresh() { unlinkSyncMock.mockClear() parseDaemonPidFileMock.mockReset() parseDaemonPidFileMock.mockReturnValue(null) + unlinkOwnedDaemonPidFileMock.mockReset() + unlinkOwnedDaemonPidFileMock.mockReturnValue(true) getProcessStartedAtMsMock.mockReset() getProcessStartedAtMsMock.mockReturnValue(1_000_000) // Why: importing daemon-init *after* resetModules means the module-level @@ -393,6 +458,16 @@ async function importFresh() { return import('./daemon-init') } +function mockConnectedAdoptionClientOnce(): void { + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(), + disconnect: vi.fn() + } + }) +} + describe('daemon-init: runRestartDaemon (7-step sequence)', () => { beforeEach(() => { probeSocketExistsMock.mockReturnValue(false) @@ -429,6 +504,50 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(rebindLocalProviderListenersMock.mock.invocationCallOrder[0]).toBeGreaterThan( setLocalPtyProviderMock.mock.invocationCallOrder[0] ) + expect(adapterInstances[0].establishLifecycleLease).toHaveBeenCalledOnce() + expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( + setLocalPtyProviderMock.mock.invocationCallOrder[0] + ) + expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() + expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( + adoptionLeaseReleases[0].mock.invocationCallOrder[0] + ) + }) + + it('uses daemon-owned idle retirement when a fresh launch fails permanent adoption', async () => { + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/launched-socket', + tokenPath: '/fake/launched-token' + })) + lifecycleLeaseErrors.push(new Error('lease identity mismatch')) + + await expect(mod.initDaemonPtyProvider()).rejects.toThrow('lease identity mismatch') + + expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(adapterInstances[0].dispose).not.toHaveBeenCalled() + expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() + expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( + adoptionLeaseReleases[0].mock.invocationCallOrder[0] + ) + expect(setLocalPtyProviderMock).not.toHaveBeenCalled() + }) + + it('does not kill a preserved daemon when startup lease acquisition fails', async () => { + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/preserved-socket', + tokenPath: '/fake/preserved-token' + })) + lifecycleLeaseErrors.push(new Error('preserved lease failed')) + + await expect(mod.initDaemonPtyProvider()).rejects.toThrow('preserved lease failed') + + expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() + expect(setLocalPtyProviderMock).not.toHaveBeenCalled() }) it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => { @@ -474,12 +593,100 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { resolveEnsureRunning({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' }) await started - expect(adapterInstances).toHaveLength(0) + expect(adapterInstances).toHaveLength(1) + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(adapterInstances[0].establishLifecycleLease).not.toHaveBeenCalled() expect(setLocalPtyProviderMock).not.toHaveBeenCalled() expect(rebindLocalProviderListenersMock).not.toHaveBeenCalled() expect(mod.getDaemonProvider()).toBeNull() }) + it('disconnects uninstalled adapter leases when startup aborts during legacy discovery', async () => { + const mod = await importFresh() + probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false) + netConnectMock.mockImplementation(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'connect') { + queueMicrotask(() => cb()) + } + return this + }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + destroy() {} + } + }) + let resolveDiscovery!: (sessions: { sessionId: string }[]) => void + const discovery = new Promise<{ sessionId: string }[]>((resolve) => { + resolveDiscovery = resolve + }) + listProcessesControl.current = () => discovery + const abortController = new AbortController() + + const started = mod.initDaemonPtyProvider(abortController.signal) + await vi.waitFor(() => { + expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true) + expect( + adapterInstances.some((instance) => instance.listProcesses.mock.calls.length > 0) + ).toBe(true) + }) + abortController.abort() + resolveDiscovery([]) + await started + + expect(adapterInstances).toHaveLength(2) + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() + expect(setLocalPtyProviderMock).not.toHaveBeenCalled() + expect(mod.getDaemonProvider()).toBeNull() + }) + + it('retains every adapter cleanup failure when legacy router setup aborts', async () => { + const mod = await importFresh() + probeSocketExistsMock.mockImplementation( + (path?: string) => path?.endsWith('daemon-v9.sock') ?? false + ) + netConnectMock.mockImplementation(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, callback: () => void) { + handlers[event]?.push(callback) + if (event === 'connect') { + queueMicrotask(() => callback()) + } + return this + }, + removeListener(event: string, callback: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + destroy() {} + } + }) + const discoveryError = new Error('router subscription failed') + const currentCleanupError = new Error('current cleanup failed') + const legacyCleanupError = new Error('legacy cleanup failed') + routerSubscriptionError.current = discoveryError + disconnectOnlyErrors.push(currentCleanupError, legacyCleanupError) + + const error = await mod.initDaemonPtyProvider().catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(AggregateError) + const topLevelErrors = (error as AggregateError).errors + expect(topLevelErrors[0]).toBe(discoveryError) + expect(topLevelErrors[1]).toBeInstanceOf(AggregateError) + expect((topLevelErrors[1] as AggregateError).errors).toEqual([ + legacyCleanupError, + currentCleanupError + ]) + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() + }) + it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ @@ -537,6 +744,26 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(order).toEqual(['fanout', 'unbind']) }) + it('uses daemon-owned idle retirement after a failed manual-restart adoption', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const originalProvider = mod.getDaemonProvider() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/restart-failure-socket', + tokenPath: '/fake/restart-failure-token' + })) + lifecycleLeaseErrors.push(new Error('restart lease failed')) + + await expect(mod.restartDaemon()).rejects.toThrow('restart lease failed') + + expect(adoptionLeaseReleases[1]).toHaveBeenCalledOnce() + expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() + expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() + expect(mod.getDaemonProvider()).toBe(originalProvider) + expect(unbindLocalProviderListenersMock).toHaveBeenCalledOnce() + expect(rebindLocalProviderListenersMock).toHaveBeenCalledTimes(2) + }) + it('fans exits for preserved degraded current-daemon sessions during restart', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ @@ -801,6 +1028,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // result, so its existence proves the ordering. expect(adapterInstances).toHaveLength(2) expect(adapterInstances[1].options.socketPath).toBe('/fake/socket-2') + expect(adapterInstances[1].establishLifecycleLease).toHaveBeenCalledOnce() + expect(adapterInstances[1].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( + setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) as number + ) }) it('exercises the alive-daemon cleanup path: issues shutdown RPC via DaemonClient before spawning a replacement', async () => { @@ -820,9 +1051,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) const ensureConnectedMock = vi.fn(async () => {}) const disconnectMock = vi.fn() - // Why: DaemonClient is invoked via `new DaemonClient(...)`, so the mock - // factory must return a constructor-compatible function. Capture the - // existing impl so later tests aren't affected. + const mod = await importFresh() + await mod.initDaemonPtyProvider() daemonClientMock.mockImplementationOnce(function MockDaemonClientForShutdown() { return { ensureConnected: ensureConnectedMock, @@ -831,9 +1061,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) - const mod = await importFresh() - await mod.initDaemonPtyProvider() - // Make probeSocket return true for the current-version path by toggling // both the fs.existsSync proxy AND net.connect resolving "alive". probeSocketExistsMock.mockReturnValue(true) @@ -996,7 +1223,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1036,6 +1263,79 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) }) + it('holds a full adoption pair before a healthy launcher resolves', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const events: string[] = [] + const disconnect = vi.fn() + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => { + events.push('full-pair') + }), + request: vi.fn(), + disconnect + } + }) + checkDaemonHealthMock.mockImplementationOnce(async () => { + events.push('health') + return 'healthy' + }) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ + releaseAdoptionLease?(): void + shutdown(): Promise + }> + + const handle = await launcher('/fake/socket', '/fake/token') + + expect(events[0]).toBe('full-pair') + expect(events.indexOf('full-pair')).toBeLessThan(events.indexOf('health')) + expect(disconnect).not.toHaveBeenCalled() + handle.releaseAdoptionLease?.() + expect(disconnect).toHaveBeenCalledOnce() + }) + + it('disconnects every temporary client when healthy adoption fails', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const initialDisconnect = vi.fn() + const replacementDisconnect = vi.fn() + daemonClientMock + .mockImplementationOnce(function MockInitialAdoptionClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('initial adoption failed') + }), + request: vi.fn(), + disconnect: initialDisconnect + } + }) + .mockImplementationOnce(function MockReplacementAdoptionClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('replacement adoption failed') + }), + request: vi.fn(), + disconnect: replacementDisconnect + } + }) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + + await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( + 'replacement adoption failed' + ) + + expect(initialDisconnect).toHaveBeenCalledOnce() + expect(replacementDisconnect).toHaveBeenCalledOnce() + expect(forkMock).not.toHaveBeenCalled() + }) + it('preserves a daemon launched from another app path when it owns live sessions', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1052,6 +1352,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1091,6 +1392,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1133,7 +1435,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1185,6 +1487,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1220,6 +1523,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1264,7 +1568,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1316,7 +1620,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1336,16 +1640,355 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() - expect(writeFileSyncMock).toHaveBeenCalledWith( - `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, - JSON.stringify({ - pid: 12345, - startedAtMs: 1_000_000, - entryPath: FAKE_DAEMON_ENTRY_PATH, - appVersion: '1.2.3' - }), - { mode: 0o600 } + const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? [] + expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`) + expect(JSON.parse(pidContents as string)).toEqual({ + pid: 12345, + startedAtMs: 1_000_000, + entryPath: FAKE_DAEMON_ENTRY_PATH, + appVersion: '1.2.3', + launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) + }) + expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) + const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[] + const launchNonceIndex = launchArgs.indexOf('--launch-nonce') + expect(launchArgs).toEqual( + expect.arrayContaining([ + '--pid-record', + `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, + '--launch-nonce' + ]) ) + expect(launchArgs[launchNonceIndex + 1]).toBe(JSON.parse(pidContents as string).launchNonce) + }) + + it('keeps a live PID record after adoption failure and removes it on exact child exit', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const adoptionDisconnects: ReturnType[] = [] + function MockFailingAdoptionClient() { + const disconnect = vi.fn() + adoptionDisconnects.push(disconnect) + return { + ensureConnected: vi.fn(async () => { + throw new Error('adoption unavailable') + }), + request: vi.fn(), + disconnect + } + } + for (let index = 0; index < 3; index++) { + daemonClientMock.mockImplementationOnce(MockFailingAdoptionClient) + } + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + on(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + once(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + return this + }, + off(event: string, callback: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ shutdown(): Promise }> + + await expect( + launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-delayed') + ).rejects.toThrow('adoption unavailable') + + expect(writeFileSyncMock).toHaveBeenCalledWith( + '/fake/daemon.pid', + expect.stringContaining('launch-delayed'), + { mode: 0o600, flag: 'wx' } + ) + expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled() + expect(adoptionDisconnects.at(-1)).toHaveBeenCalledOnce() + + child.exitCode = 0 + for (const callback of handlers.exit.slice()) { + callback(0) + } + expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( + '/fake/daemon.pid', + 12345, + 'launch-delayed' + ) + }) + + it('kills and rejects a daemon whose readiness message omits its start time', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) + }) + const child = { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + try { + await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( + 'Daemon readiness identity is incomplete' + ) + expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') + expect(writeFileSyncMock).not.toHaveBeenCalled() + expect(child.disconnect).not.toHaveBeenCalled() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + } + }) + + it('rejects startup cleanup when SIGKILL never produces child exit', async () => { + vi.useFakeTimers() + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + try { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready' })) + } + return this + }, + off(event: string, callback: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + + const launch = launcher('/fake/socket', '/fake/token') + await Promise.resolve() + await Promise.resolve() + const rejection = expect(launch).rejects.toThrow('startup and child cleanup both failed') + await vi.advanceTimersByTimeAsync(6_000) + await rejection + + expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + vi.useRealTimers() + } + }) + + it('surfaces non-ESRCH startup termination errors and releases IPC', async () => { + const signalError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw signalError + }) + try { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on(event: string, callback: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready' })) + } + return this + }, + off: vi.fn(), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + + const error = await launcher('/fake/socket', '/fake/token').catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + signalError + ]) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + } + }) + + it('settles startup with both errors when a malformed-ready child ignores termination', async () => { + vi.useFakeTimers() + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + try { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready' })) + } + return this + }, + off(event: string, callback: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + + const launch = launcher('/fake/socket', '/fake/token').catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(6_000) + const error = await launch + + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + expect.objectContaining({ message: 'Daemon did not exit after SIGKILL' }) + ]) + expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') + expect(handlers.message).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + expect(handlers.exit).toHaveLength(0) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + vi.useRealTimers() + } + }) + + it('kills and rejects a daemon when exclusive PID publication fails', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) + }) + const child = { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + off: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn() + } + const publicationError = Object.assign(new Error('PID record already exists'), { + code: 'EEXIST' + }) + writeFileSyncMock.mockImplementationOnce(() => { + throw publicationError + }) + forkMock.mockReturnValueOnce(child) + + try { + await expect(launcher('/fake/socket', '/fake/token')).rejects.toBe(publicationError) + expect(writeFileSyncMock).toHaveBeenCalledWith( + `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, + expect.any(String), + { mode: 0o600, flag: 'wx' } + ) + expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') + expect(child.disconnect).not.toHaveBeenCalled() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + } }) it('removes detached daemon startup listeners after startup error', async () => { @@ -1390,7 +2033,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(handlers.error).toHaveLength(0) expect(handlers.exit).toHaveLength(0) expect(child.disconnect).not.toHaveBeenCalled() - expect(child.unref).not.toHaveBeenCalled() + expect(child.unref).toHaveBeenCalledOnce() }) it('captures daemon startup stderr into the failure error', async () => { @@ -1429,6 +2072,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } const child = { pid: 4321, + exitCode: null as number | null, stderr, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) @@ -1439,6 +2083,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { for (const dataCb of stderrDataCbs.slice()) { dataCb(Buffer.from("Error: Cannot find module 'electron'\n")) } + child.exitCode = 1 cb(1) }) } @@ -1492,10 +2137,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, + once(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + return this + }, off: vi.fn(() => child), disconnect: vi.fn(), unref: vi.fn() @@ -1523,6 +2172,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1557,6 +2207,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } return {} }) + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1602,10 +2253,13 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, + once() { + return this + }, off() { return this }, @@ -1700,11 +2354,16 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { disconnect: vi.fn() } } - // Every probe across the whole grace window times out (permanent wedge). + // Every probe across the whole grace window times out (permanent wedge), + // then the freshly spawned daemon accepts the temporary adoption lease. + let daemonClientConstructionCount = 0 daemonClientMock.mockImplementation(function MockDaemonClient() { + daemonClientConstructionCount++ return { ensureConnected: vi.fn(async () => { - throw new Error('Hello response timed out') + if (daemonClientConstructionCount <= 2 + WEDGED_DAEMON_GRACE_RETRIES) { + throw new Error('Hello response timed out') + } }), request: vi.fn(), disconnect: vi.fn() @@ -1722,7 +2381,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1747,7 +2406,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() // The launcher probes the full grace budget before giving up: 1 initial // probe + WEDGED_DAEMON_GRACE_RETRIES retries. - expect(daemonClientMock).toHaveBeenCalledTimes(1 + WEDGED_DAEMON_GRACE_RETRIES) + expect(daemonClientMock).toHaveBeenCalledTimes(3 + WEDGED_DAEMON_GRACE_RETRIES) } finally { // Restore the answering default so the persistent throwing impl above // does not leak into later tests (clearAllMocks clears calls, not impls). @@ -1842,7 +2501,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1864,8 +2523,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() // Pins the 'rejected' fast-path: a daemon that actively refuses the // handshake is never worth a grace window, so it is probed exactly once - // (no retries) before replacement. - expect(daemonClientMock).toHaveBeenCalledTimes(1) + // (no retries) before replacement. The other clients are the initial + // adoption attempt and the fresh daemon's temporary adoption lease. + expect(daemonClientMock).toHaveBeenCalledTimes(3) }) it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => { @@ -1917,16 +2577,16 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') - expect(writeFileSyncMock).toHaveBeenCalledWith( - `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, - JSON.stringify({ - pid: 12345, - startedAtMs: 1_700_000_123_456, - entryPath: FAKE_DAEMON_ENTRY_PATH, - appVersion: '1.2.3' - }), - { mode: 0o600 } - ) + const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? [] + expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`) + expect(JSON.parse(pidContents as string)).toEqual({ + pid: 12345, + startedAtMs: 1_700_000_123_456, + entryPath: FAKE_DAEMON_ENTRY_PATH, + appVersion: '1.2.3', + launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) + }) + expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) }) it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { @@ -1987,7 +2647,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -2073,7 +2733,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -2126,6 +2786,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 1bbc2cad9..093cb7a00 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -7,9 +7,10 @@ files with no cleaner ownership seam: restart, replaceDaemonProvider, and the module-level spawner/adapter singletons must stay co-located so a future change cannot leave them drifting out of sync. */ import { join } from 'node:path' +import { randomUUID } from 'node:crypto' import { app } from 'electron' import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { fork } from 'node:child_process' +import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' import { DaemonSpawner, @@ -17,6 +18,7 @@ import { getDaemonSocketPath, getDaemonTokenPath, serializeDaemonPidFile, + unlinkOwnedDaemonPidFile, type DaemonLauncher, type DaemonProcessHandle } from './daemon-spawner' @@ -24,6 +26,7 @@ import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' import { + CLEAN_DISCONNECT_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, type ListSessionsResult @@ -31,7 +34,6 @@ import { import { getMacDaemonSystemResolverHealth, getDaemonLaunchIdentity, - getProcessStartedAtMs, checkDaemonHealth, isDaemonStaleForCurrentBundle, killStaleDaemon, @@ -82,6 +84,9 @@ function logDaemonMilestone(event: string, details: Record = {} // that takes longer than ~60s to drain is replaced (live processes lost, though // scrollback cold-restores). Raise this only alongside the fail-open cap. export const WEDGED_DAEMON_GRACE_RETRIES = 11 +const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000 +const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 +const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 let spawner: DaemonSpawner | null = null type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider @@ -205,6 +210,134 @@ function createPreservedDaemonHandle( return handle } +async function holdDaemonAdoptionLease( + handle: DaemonProcessHandle, + socketPath: string, + tokenPath: string, + connectedClient?: DaemonClient +): Promise { + const client = connectedClient ?? new DaemonClient({ socketPath, tokenPath }) + try { + await client.ensureConnected() + } catch (error) { + client.disconnect() + throw error + } + handle.releaseAdoptionLease = () => client.disconnect() + return handle +} + +function releaseDaemonAdoptionLease(handle: DaemonProcessHandle | null): void { + takeDaemonAdoptionLeaseRelease(handle)?.() +} + +function takeDaemonAdoptionLeaseRelease( + handle: DaemonProcessHandle | null +): (() => void) | undefined { + const release = handle?.releaseAdoptionLease + if (!release || !handle) { + return undefined + } + delete handle.releaseAdoptionLease + return release +} + +async function cleanupFailedDaemonAdoption( + failedSpawner: DaemonSpawner, + current: DaemonPtyAdapter, + legacy: DaemonPtyAdapter[] = [] +): Promise { + const handle = failedSpawner.getHandle() + const results = await Promise.allSettled([ + Promise.resolve().then(() => releaseDaemonAdoptionLease(handle)), + ...legacy.map((entry) => entry.disconnectOnly()), + (async () => { + try { + // Why: endpoint publication allows other authenticated clients to win; + // only daemon-side shutdownIfIdle may prove a failed adoption is killable. + await current.disconnectOnly() + } catch (error) { + current.dispose() + throw error + } + })() + ]) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length > 0) { + throw new AggregateError(failures, 'Daemon adoption cleanup failed') + } +} + +async function terminateLaunchedDaemonChild(child: ChildProcess): Promise { + try { + if ( + (child.exitCode !== null && child.exitCode !== undefined) || + (child.signalCode !== null && child.signalCode !== undefined) + ) { + return + } + await new Promise((resolve, reject) => { + let gracefulTimer: ReturnType + let forcedTimer: ReturnType | undefined + let settled = false + const finish = (error?: unknown): void => { + if (settled) { + return + } + settled = true + clearTimeout(gracefulTimer) + if (forcedTimer) { + clearTimeout(forcedTimer) + } + child.off('exit', onExit) + if (error) { + reject(error) + } else { + resolve() + } + } + const onExit = (): void => finish() + child.on('exit', onExit) + gracefulTimer = setTimeout(() => { + if (child.pid) { + try { + process.kill(child.pid, 'SIGKILL') + } catch (error) { + finish(isNoSuchProcessError(error) ? undefined : error) + return + } + } + if (!settled) { + forcedTimer = setTimeout( + () => finish(new Error('Daemon did not exit after SIGKILL')), + DAEMON_CHILD_FORCE_EXIT_WAIT_MS + ) + } + }, DAEMON_CHILD_TERMINATION_GRACE_MS) + if (child.pid) { + try { + process.kill(child.pid, 'SIGTERM') + } catch (error) { + finish(isNoSuchProcessError(error) ? undefined : error) + } + } else { + finish() + } + }) + } finally { + if (child.connected) { + child.disconnect() + } + child.unref() + } +} + +function isNoSuchProcessError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' +} + async function shouldPreserveDaemonWithLiveSessions( socketPath: string, tokenPath: string, @@ -223,275 +356,351 @@ async function shouldPreserveDaemonWithLiveSessions( } function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { - return async (socketPath, tokenPath) => { + return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => { const entryPath = getDaemonEntryPath() - const health = await checkDaemonHealth(socketPath, tokenPath) - if (health === 'healthy') { - const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) - if (resolverHealth === 'unhealthy') { - const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - if (liveSessionCount !== 0) { - console.warn( - liveSessionCount === null - ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' - : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return createPreservedDaemonHandle(runtimeDir) - } - console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) - } else { - // Why: a protocol-healthy daemon can outlive the app bundle that - // launched it. In dev this happens after deleting/rebuilding a - // worktree; in packaged apps it happens when the stable - // /Applications/Orca.app path is replaced during update. - const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) - const stalePackagedBundle = - app.isPackaged && - (await isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion())) - if (identity === 'mismatch' || stalePackagedBundle) { - // Why: replacing a healthy daemon kills its child PTYs; defer code - // freshness until no live terminal sessions would be lost. - const replacementLabel = stalePackagedBundle - ? 'launched before the current app bundle was installed' - : 'launched from a different app path' - if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) { - return createPreservedDaemonHandle(runtimeDir) + const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir) + const launchNonce = suppliedLaunchNonce ?? randomUUID() + let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath }) + try { + // Why: acquire the full pair before any control-only probes so an expired + // inherited deadline cannot fire in the probe-to-adoption gap. + await adoptionClient.ensureConnected() + } catch { + adoptionClient.disconnect() + adoptionClient = null + } + const preserveDaemon = async ( + mode?: 'degraded-new-pty-fallback' + ): Promise => { + const connectedClient = adoptionClient ?? undefined + adoptionClient = null + return holdDaemonAdoptionLease( + createPreservedDaemonHandle(runtimeDir, PROTOCOL_VERSION, mode), + socketPath, + tokenPath, + connectedClient + ) + } + try { + const health = await checkDaemonHealth(socketPath, tokenPath) + if (health === 'healthy') { + const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) + if (resolverHealth === 'unhealthy') { + const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + if (liveSessionCount !== 0) { + console.warn( + liveSessionCount === null + ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' + : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return preserveDaemon() } - console.warn( - stalePackagedBundle - ? '[daemon] Replacing daemon launched before the current app bundle was installed' - : '[daemon] Replacing daemon launched from a different app path' - ) + console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { - // Why: daemon is already running from a previous app session and - // responded to a protocol-level ping. Safe to reuse. - return createPreservedDaemonHandle(runtimeDir) - } - } - } else { - // Why: a busy machine (e.g. right after an update) can time out the - // health check while the daemon is alive and owning terminals. Killing - // it would destroy every live session, so re-verify with a session list - // first. - let liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - // Why: on a Windows update relaunch the daemon can be transiently wedged - // past every RPC budget (final checkpoint flush + installer/AV disk - // pressure) while its sessions are still alive — replacing it here is what - // killed those sessions. A pipe that still accepts connections proves a - // live daemon, so give a wedged-but-connectable daemon a bounded grace to - // drain and answer before deciding. A PERMANENTLY wedged daemon (accepts - // connections but its event loop never answers hello — #8689) exhausts the - // grace and falls through to replacement below, instead of being preserved - // forever, which strands the app with zero working terminals. 'rejected' - // means the daemon answered and refused the handshake — it can never be - // adopted, so it skips the grace and replacement stays the only recovery. - let graceRetry = 0 - while ( - liveSessionCount === null && - health !== 'rejected' && - graceRetry < WEDGED_DAEMON_GRACE_RETRIES && - (await probeSocket(socketPath)) - ) { - liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - graceRetry++ - } - if (liveSessionCount !== null && liveSessionCount > 0) { - if (health === 'pty-spawn-unhealthy') { - console.warn( - `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).` - ) - return createPreservedDaemonHandle( + // Why: a protocol-healthy daemon can outlive the app bundle that + // launched it. In dev this happens after deleting/rebuilding a + // worktree; in packaged apps it happens when the stable + // /Applications/Orca.app path is replaced during update. + const identity = await getDaemonLaunchIdentity( runtimeDir, - PROTOCOL_VERSION, - 'degraded-new-pty-fallback' + socketPath, + tokenPath, + entryPath ) - } - console.warn( - `[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return createPreservedDaemonHandle(runtimeDir) - } - } - - // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID - // before respawn so the new daemon does not race the stale process. - await killStaleDaemon(runtimeDir, socketPath, tokenPath) - - const userDataPath = app.getPath('userData') - // Why: on win32 packaged, fork from a copy of the Electron runtime staged - // in userData so the daemon's image + loaded modules escape the install dir - // the NSIS updater deletes and force-closes. Staged here (not at app start) - // so the one-time copy stays off the first-paint path and is skipped on - // launches that adopt a live daemon. Fail-open: null → in-dir host, below. - const relocatedHost = materializeRelocatedDaemonHost() - // Fork the relocated entry when available; otherwise the install-dir entry. - const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath - const child = fork( - forkEntryPath, - ['--socket', socketPath, '--token', tokenPath, ...daemonLogArgs()], - { - // Why: detached daemons can outlive dev worktrees. Starting from - // userData keeps process.cwd() valid after a repo/worktree is deleted. - cwd: userDataPath, - // Why: detached + unref lets the daemon outlive the Electron process. - // stdout stays 'ignore' so the child never holds the parent's stdout - // open (which would block Electron exit); stderr is 'pipe' so a - // module-load crash during startup is captured instead of discarded - // (v1.4.129-rc.1 shipped a daemon that only logged "exited with code 1" - // because stderr was thrown away). The pipe is destroyed on readiness. - detached: true, - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - // Why: run the relocated Orca.exe copy instead of the install-dir one. - // It is byte-identical, so run-as-node behavior is unchanged; only the - // image path moves out of the updater's kill zone. - ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}), - // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain - // Node.js process instead of an Electron renderer/main process. Without - // it, Electron's GPU/display initialization can interfere with native - // module operations like node-pty's posix_spawn of the spawn-helper. - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - // Why: the detached daemon is plain Node and cannot call Electron's - // app.getPath(), but shell-ready rcfiles must live outside swept tmp. - ORCA_USER_DATA_PATH: userDataPath - } - } - ) - - // Why: keep only the startup-window stderr tail so a crash cause is - // visible without unbounded memory if the daemon spews before dying. - const STARTUP_STDERR_MAX_BYTES = 8192 - let startupStderr = '' - let collectingStderr = true - const onStartupStderr = (chunk: Buffer): void => { - if (!collectingStderr) { - return - } - startupStderr += chunk.toString('utf8') - if (startupStderr.length > STARTUP_STDERR_MAX_BYTES) { - startupStderr = startupStderr.slice(-STARTUP_STDERR_MAX_BYTES) - } - } - child.stderr?.on('data', onStartupStderr) - // Why: once the daemon is up (or has failed) the parent must not keep a - // live handle on the detached daemon's stderr — a piped stream would ref - // the parent event loop and prevent Electron from exiting cleanly. - const releaseStderr = (): void => { - collectingStderr = false - child.stderr?.off('data', onStartupStderr) - child.stderr?.destroy() - } - - // Wait for the daemon to signal readiness via IPC - await new Promise((resolve, reject) => { - let timer: ReturnType | undefined - let settled = false - function cleanupStartupListeners(): void { - if (timer) { - clearTimeout(timer) - } - child.off('message', onReadyMessage) - child.off('error', onStartupError) - child.off('exit', onStartupExit) - } - function fail(error: Error): void { - if (settled) { - return - } - settled = true - cleanupStartupListeners() - // Why: stderr was previously discarded, so a startup crash surfaced only - // as "exited with code 1". Attach the captured tail to the thrown error - // (which the fallback path reports) and log it so the real cause shows. - const stderrTail = startupStderr.trim() - if (stderrTail) { - console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`) - } - releaseStderr() - if (child.pid) { - try { - process.kill(child.pid, 'SIGTERM') - } catch { - // Already dead + const stalePackagedBundle = + app.isPackaged && + (await isDaemonStaleForCurrentBundle( + runtimeDir, + socketPath, + tokenPath, + app.getVersion() + )) + if (identity === 'mismatch' || stalePackagedBundle) { + // Why: replacing a healthy daemon kills its child PTYs; defer code + // freshness until no live terminal sessions would be lost. + const replacementLabel = stalePackagedBundle + ? 'launched before the current app bundle was installed' + : 'launched from a different app path' + if ( + await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel) + ) { + return preserveDaemon() + } + console.warn( + stalePackagedBundle + ? '[daemon] Replacing daemon launched before the current app bundle was installed' + : '[daemon] Replacing daemon launched from a different app path' + ) + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + } else { + // Why: daemon is already running from a previous app session and + // responded to a protocol-level ping. Safe to reuse. + return preserveDaemon() } } - reject( - stderrTail ? new Error(`${error.message}\nDaemon stderr (tail):\n${stderrTail}`) : error - ) + } else { + // Why: a busy machine (e.g. right after an update) can time out the + // health check while the daemon is alive and owning terminals. Killing + // it would destroy every live session, so re-verify with a session list + // first. + let liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + // Why: on a Windows update relaunch the daemon can be transiently wedged + // past every RPC budget (final checkpoint flush + installer/AV disk + // pressure) while its sessions are still alive — replacing it here is what + // killed those sessions. A pipe that still accepts connections proves a + // live daemon, so give a wedged-but-connectable daemon a bounded grace to + // drain and answer before deciding. A PERMANENTLY wedged daemon (accepts + // connections but its event loop never answers hello — #8689) exhausts the + // grace and falls through to replacement below, instead of being preserved + // forever, which strands the app with zero working terminals. 'rejected' + // means the daemon answered and refused the handshake — it can never be + // adopted, so it skips the grace and replacement stays the only recovery. + let graceRetry = 0 + while ( + liveSessionCount === null && + health !== 'rejected' && + graceRetry < WEDGED_DAEMON_GRACE_RETRIES && + (await probeSocket(socketPath)) + ) { + liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + graceRetry++ + } + if (liveSessionCount !== null && liveSessionCount > 0) { + if (health === 'pty-spawn-unhealthy') { + console.warn( + `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).` + ) + return preserveDaemon('degraded-new-pty-fallback') + } + console.warn( + `[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return preserveDaemon() + } } - function onReadyMessage(msg: unknown): void { - if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') { + + // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID + // before respawn so the new daemon does not race the stale process. + adoptionClient?.disconnect() + adoptionClient = null + await killStaleDaemon(runtimeDir, socketPath, tokenPath) + + const userDataPath = app.getPath('userData') + // Why: on win32 packaged, fork from a copy of the Electron runtime staged + // in userData so the daemon's image + loaded modules escape the install dir + // the NSIS updater deletes and force-closes. Staged here (not at app start) + // so the one-time copy stays off the first-paint path and is skipped on + // launches that adopt a live daemon. Fail-open: null → in-dir host, below. + const relocatedHost = materializeRelocatedDaemonHost() + // Fork the relocated entry when available; otherwise the install-dir entry. + const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath + const child = fork( + forkEntryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + ...daemonLogArgs() + ], + { + // Why: detached daemons can outlive dev worktrees. Starting from + // userData keeps process.cwd() valid after a repo/worktree is deleted. + cwd: userDataPath, + // Why: detached + unref lets the daemon outlive the Electron process. + // stdout stays 'ignore' so the child never holds the parent's stdout + // open (which would block Electron exit); stderr is 'pipe' so a + // module-load crash during startup is captured instead of discarded + // (v1.4.129-rc.1 shipped a daemon that only logged "exited with code 1" + // because stderr was thrown away). The pipe is destroyed on readiness. + detached: true, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + // Why: run the relocated Orca.exe copy instead of the install-dir one. + // It is byte-identical, so run-as-node behavior is unchanged; only the + // image path moves out of the updater's kill zone. + ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}), + // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain + // Node.js process instead of an Electron renderer/main process. Without + // it, Electron's GPU/display initialization can interfere with native + // module operations like node-pty's posix_spawn of the spawn-helper. + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + // Why: the detached daemon is plain Node and cannot call Electron's + // app.getPath(), but shell-ready rcfiles must live outside swept tmp. + ORCA_USER_DATA_PATH: userDataPath + } + } + ) + + // Why: keep only the startup-window stderr tail so a crash cause is + // visible without unbounded memory if the daemon spews before dying. + const STARTUP_STDERR_MAX_BYTES = 8192 + let startupStderr = '' + let collectingStderr = true + const onStartupStderr = (chunk: Buffer): void => { + if (!collectingStderr) { + return + } + startupStderr += chunk.toString('utf8') + if (startupStderr.length > STARTUP_STDERR_MAX_BYTES) { + startupStderr = startupStderr.slice(-STARTUP_STDERR_MAX_BYTES) + } + } + child.stderr?.on('data', onStartupStderr) + // Why: once the daemon is up (or has failed) the parent must not keep a + // live handle on the detached daemon's stderr — a piped stream would ref + // the parent event loop and prevent Electron from exiting cleanly. + const releaseStderr = (): void => { + collectingStderr = false + child.stderr?.off('data', onStartupStderr) + child.stderr?.destroy() + } + + // Wait for the daemon to signal readiness via IPC + await new Promise((resolve, reject) => { + let timer: ReturnType | undefined + let settled = false + function cleanupStartupListeners(): void { + if (timer) { + clearTimeout(timer) + } + child.off('message', onReadyMessage) + child.off('error', onStartupError) + child.off('exit', onStartupExit) + } + async function fail(error: Error): Promise { if (settled) { return } settled = true - // Why: the daemon process is detached after readiness; leaving - // startup listeners attached retains this launch promise closure. cleanupStartupListeners() - if (child.pid) { - // Why: JSON pid file carries pid + process start time so later - // killStaleDaemon() can verify the pid still belongs to the daemon - // we forked before SIGTERMing it. Prevents pid-recycling hazard - // where the OS hands the daemon's old pid to an unrelated process. - // Why the ready-message fallback: Windows has no cheap OS query - // for start time, so the daemon self-reports it — without this the - // recycling guard was permanently inert on win32. - const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs - writeFileSync( - getDaemonPidPath(runtimeDir), - serializeDaemonPidFile({ - pid: child.pid, - startedAtMs: - getProcessStartedAtMs(child.pid) ?? - (typeof selfReported === 'number' && Number.isFinite(selfReported) - ? selfReported - : null), - entryPath, - appVersion: app.getVersion() - }), - { mode: 0o600 } - ) + // Why: stderr was previously discarded, so a startup crash surfaced only + // as "exited with code 1". Attach the captured tail to the thrown error + // (which the fallback path reports) and log it so the real cause shows. + const stderrTail = startupStderr.trim() + if (stderrTail) { + console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`) } - // Why: disconnect IPC channel, release the stderr pipe, and unref so - // Electron can exit without waiting for the daemon. The daemon keeps - // running detached. releaseStderr() - child.disconnect() - child.unref() - resolve() - } - } - - function onStartupError(err: Error): void { - fail(err) - } - - function onStartupExit(code: number | null): void { - fail(new Error(`Daemon exited during startup with code ${code}`)) - } - - timer = setTimeout(() => { - fail(new Error('Daemon startup timed out')) - }, 10000) - - child.on('message', onReadyMessage) - child.on('error', onStartupError) - child.on('exit', onStartupExit) - }) - - return { - shutdown: async () => { - if (child.pid) { + const startupError = stderrTail + ? new Error(`${error.message}\nDaemon stderr (tail):\n${stderrTail}`) + : error try { - process.kill(child.pid, 'SIGTERM') - } catch { - // Already dead + await terminateLaunchedDaemonChild(child) + } catch (cleanupError) { + reject( + new AggregateError( + [startupError, cleanupError], + 'Daemon startup and child cleanup both failed' + ) + ) + return + } + reject(startupError) + } + function onReadyMessage(msg: unknown): void { + if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') { + if (settled) { + return + } + const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs + if ( + !Number.isSafeInteger(child.pid) || + (child.pid as number) <= 0 || + typeof selfReported !== 'number' || + !Number.isFinite(selfReported) || + selfReported <= 0 + ) { + void fail(new Error('Daemon readiness identity is incomplete')) + return + } + try { + // Why: hello and the PID record must share the daemon's self time + // and nonce so cleanup can identify this exact process incarnation. + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: child.pid as number, + startedAtMs: selfReported, + entryPath, + appVersion: app.getVersion(), + launchNonce + }), + { mode: 0o600, flag: 'wx' } + ) + } catch (error) { + void fail(error instanceof Error ? error : new Error(String(error))) + return + } + settled = true + // Why: the daemon process is detached after readiness; leaving + // startup listeners attached retains this launch promise closure. + cleanupStartupListeners() + // Why: disconnect IPC channel, release the stderr pipe, and unref so + // Electron can exit without waiting for the daemon. The daemon keeps + // running detached. + releaseStderr() + child.disconnect() + child.unref() + resolve() } } + + function onStartupError(err: Error): void { + void fail(err) + } + + function onStartupExit(code: number | null): void { + void fail(new Error(`Daemon exited during startup with code ${code}`)) + } + + timer = setTimeout(() => { + void fail(new Error('Daemon startup timed out')) + }, 10000) + + child.on('message', onReadyMessage) + child.on('error', onStartupError) + child.on('exit', onStartupExit) + }) + + try { + return await holdDaemonAdoptionLease( + { + shutdown: () => terminateLaunchedDaemonChild(child) + }, + socketPath, + tokenPath + ) + } catch (error) { + // Why: another client may have adopted this still-live process. Keep its + // valid identity until exit, but remove a record published after an early exit. + let pidRecordRemoved = false + const removeExitedPidRecord = (): void => { + if (pidRecordRemoved) { + return + } + pidRecordRemoved = true + unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) + } + child.once('exit', removeExitedPidRecord) + if ( + (child.exitCode !== null && child.exitCode !== undefined) || + (child.signalCode !== null && child.signalCode !== undefined) + ) { + child.off('exit', removeExitedPidRecord) + removeExitedPidRecord() + } + throw error } + } catch (error) { + adoptionClient?.disconnect() + throw error } } } @@ -525,7 +734,14 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise logDaemonMilestone('daemon-current-ready') if (signal?.aborted) { // Why: startup fail-open may already have allowed fallback LocalPtyProvider - // PTYs to spawn. A late daemon swap would strand those PTYs on the old owner. + // PTYs to spawn. Do not install late, but give an empty daemon one bounded + // authenticated retirement attempt; live adopted sessions reject it and survive. + const abortedStartupAdapter = new DaemonPtyAdapter({ + socketPath: info.socketPath, + tokenPath: info.tokenPath + }) + releaseDaemonAdoptionLease(newSpawner.getHandle()) + await abortedStartupAdapter.disconnectOnly() return } @@ -541,37 +757,54 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise console.warn('[daemon] Daemon process died — respawning') newSpawner.resetHandle() await newSpawner.ensureRunning() + return takeDaemonAdoptionLeaseRelease(newSpawner.getHandle()) } }) + let legacyAdapters: DaemonPtyAdapter[] = [] + let routedAdapter: DaemonProvider = newAdapter + try { + // Why: the launcher's temporary pair closes only after this permanent + // adapter pair is established, leaving no retirement gap during adoption. + await newAdapter.establishLifecycleLease() + releaseDaemonAdoptionLease(newSpawner.getHandle()) - const legacyAdapters = await createLegacyDaemonAdapters(runtimeDir) - const routedAdapter = - launchMode === 'degraded-new-pty-fallback' - ? new DegradedDaemonPtyProvider({ - current: newAdapter, - legacy: legacyAdapters, - fallback: getLocalPtyProvider() - }) - : legacyAdapters.length > 0 - ? new DaemonPtyRouter({ + legacyAdapters = await createLegacyDaemonAdapters(runtimeDir) + routedAdapter = + launchMode === 'degraded-new-pty-fallback' + ? new DegradedDaemonPtyProvider({ current: newAdapter, - legacy: legacyAdapters + legacy: legacyAdapters, + fallback: getLocalPtyProvider() }) - : newAdapter - if (routedAdapter instanceof DegradedDaemonPtyProvider) { - // Why: the preserved daemon cannot create fresh terminals, but its live - // sessions may still be writable. Discover those ids so only known old - // sessions route to the degraded daemon; fresh panes fall back locally. - await routedAdapter.discoverDaemonSessions() - } else if (routedAdapter instanceof DaemonPtyRouter) { - await routedAdapter.discoverLegacySessions() + : legacyAdapters.length > 0 + ? new DaemonPtyRouter({ + current: newAdapter, + legacy: legacyAdapters + }) + : newAdapter + if (routedAdapter instanceof DegradedDaemonPtyProvider) { + // Why: the preserved daemon cannot create fresh terminals, but its live + // sessions may still be writable. Discover those ids so only known old + // sessions route to the degraded daemon; fresh panes fall back locally. + await routedAdapter.discoverDaemonSessions() + } else if (routedAdapter instanceof DaemonPtyRouter) { + await routedAdapter.discoverLegacySessions() + } + if (signal?.aborted) { + // Why: same late-swap guard after legacy discovery, which can also exceed + // the first-window startup timeout on slow or stale daemon state. Release + // every uninstalled adapter lease without killing its live sessions. + await routedAdapter.disconnectOnly() + return + } + } catch (error) { + try { + await cleanupFailedDaemonAdoption(newSpawner, newAdapter, legacyAdapters) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Daemon adoption and cleanup both failed') + } + throw error } - if (signal?.aborted) { - // Why: same late-swap guard after legacy discovery, which can also exceed - // the first-window startup timeout on slow or stale daemon state. - return - } - spawner = newSpawner adapter = routedAdapter setLocalPtyProvider(routedAdapter) @@ -713,12 +946,20 @@ async function runRestartDaemon(): Promise { // Step 3: kill the current-protocol daemon process (shutdown RPC → fallback // killStaleDaemon → socket/pid unlink). Legacy adapters untouched. - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + let info: Awaited> + try { + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) - // Step 4: reuse the existing spawner so the respawn closure baked into - // long-lived adapters stays valid. Do NOT construct a new DaemonSpawner. - currentSpawner.resetHandle() - const info = await currentSpawner.ensureRunning() + // Step 4: reuse the existing spawner so the respawn closure baked into + // long-lived adapters stays valid. Do NOT construct a new DaemonSpawner. + currentSpawner.resetHandle() + info = await currentSpawner.ensureRunning() + } catch (error) { + // Why: the old provider remains authoritative until the final swap, even + // if cleanup or replacement launch fails after listener teardown. + rebindLocalProviderListeners() + throw error + } // Step 5: build a fresh current adapter against the respawned daemon. Its // respawn callback closes over the same spawner instance (identical to the @@ -731,18 +972,43 @@ async function runRestartDaemon(): Promise { console.warn('[daemon] Daemon process died — respawning') currentSpawner.resetHandle() await currentSpawner.ensureRunning() + return takeDaemonAdoptionLeaseRelease(currentSpawner.getHandle()) } }) + let newProvider: DaemonProvider = newCurrent + try { + // Why: the temporary launcher lease overlaps this permanent pair so manual + // restart cannot strand a newly spawned daemon during adoption. + await newCurrent.establishLifecycleLease() + releaseDaemonAdoptionLease(currentSpawner.getHandle()) - // Re-wrap in router if there were legacy adapters at startup; otherwise - // point straight at the new adapter. Legacy instances are preserved by - // reference — they still route to the same pre-upgrade daemons. - const newProvider = - legacyAdapters.length > 0 - ? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters }) - : newCurrent - if (newProvider instanceof DaemonPtyRouter) { - await newProvider.discoverLegacySessions() + // Re-wrap in router if there were legacy adapters at startup; otherwise + // point straight at the new adapter. Legacy instances are preserved by + // reference — they still route to the same pre-upgrade daemons. + newProvider = + legacyAdapters.length > 0 + ? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters }) + : newCurrent + if (newProvider instanceof DaemonPtyRouter) { + await newProvider.discoverLegacySessions() + } + } catch (error) { + let cleanupError: unknown + try { + if (newProvider instanceof DaemonPtyRouter) { + newProvider.disposeRouterOnly() + } + await cleanupFailedDaemonAdoption(currentSpawner, newCurrent) + } catch (caught) { + cleanupError = caught + } + // Why: the previous provider remains module-authoritative until the swap; + // restore its renderer bindings when replacement adoption fails. + rebindLocalProviderListeners() + if (cleanupError) { + throw new AggregateError([error, cleanupError], 'Daemon restart and cleanup both failed') + } + throw error } // Why: drain the outgoing router's subscriptions from the shared legacy @@ -777,11 +1043,6 @@ export async function shutdownDaemon(): Promise { adapter = null await spawner?.shutdown() spawner = null - try { - unlinkSync(getDaemonPidPath(getRuntimeDir())) - } catch { - // Best-effort - } } export type OrphanedDaemonCleanupResult = { @@ -803,6 +1064,11 @@ export async function cleanupDaemonForProtocol( const alive = await probeSocket(socketPath) if (!alive) { + if (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + // Why: endpoint absence does not prove a canonical PID record still + // belongs to the current protocol; the exact daemon owns its artifact cleanup. + return { cleaned: false, killedCount: 0 } + } // Why: still best-effort remove a stale socket file so a future opt-in // launch doesn't hit EADDRINUSE when the daemon tries to bind. if (process.platform !== 'win32' && existsSync(socketPath)) { @@ -848,6 +1114,15 @@ export async function cleanupDaemonForProtocol( client.disconnect() } + if (didRequestShutdown && protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + if (!(await waitForDaemonEndpointExit(socketPath))) { + // Why: never fork a replacement while the old incarnation may still + // own the endpoint or be disposing terminal children. + throw new Error('Timed out waiting for daemon self-shutdown') + } + return { cleaned: true, killedCount } + } + // Why: after `shutdown`, the daemon unlinks its socket itself — but on some // crash paths the file lingers. Clean up defensively so a later opt-in // relaunch can bind cleanly. @@ -867,6 +1142,17 @@ export async function cleanupDaemonForProtocol( return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount } } +async function waitForDaemonEndpointExit(socketPath: string): Promise { + const deadline = Date.now() + DAEMON_SELF_SHUTDOWN_WAIT_MS + while (Date.now() < deadline) { + if (!(await probeSocket(socketPath))) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return !(await probeSocket(socketPath)) +} + function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean { try { const parsed = parseDaemonPidFile( diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 8d2d0a36c..9884cdf0c 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -4,9 +4,16 @@ import type { DaemonFileLog } from './daemon-file-log' export type DaemonStartOptions = { socketPath: string tokenPath: string + pidPath?: string + launchNonce?: string + startedAtMs?: number + /** Direct-construction seam for versioned protocol fixtures; never CLI/env configured. */ + protocolVersion?: number spawnSubprocess: DaemonServerOptions['spawnSubprocess'] preparePtySpawn?: DaemonServerOptions['preparePtySpawn'] log?: DaemonFileLog + onIdleShutdown?: () => void + initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] } export type DaemonHandle = { @@ -17,9 +24,17 @@ export async function startDaemon(opts: DaemonStartOptions): Promise Promise + respawn?: () => Promise void)> } const MAX_TOMBSTONES = 1000 @@ -92,7 +93,9 @@ export class DaemonPtyAdapter implements IPtyProvider { private client: DaemonClient private historyManager: HistoryManager | null private historyReader: HistoryReader | null - private respawnFn: (() => Promise) | null + private respawnFn: (() => Promise void)>) | null + private pendingRespawnAdoptionRelease: (() => void) | null = null + private respawnAdoptionClosed = false // Why: multiple pane mounts can call spawn() concurrently. If the daemon is // dead, all calls enter withDaemonRetry's catch block at once. Without a // lock, each would fork its own daemon process. This promise coalesces @@ -958,6 +961,8 @@ export class DaemonPtyAdapter implements IPtyProvider { } dispose(): void { + this.respawnAdoptionClosed = true + this.releasePendingRespawnAdoptionLease() this.stopCheckpointTimer() this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() @@ -978,6 +983,15 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.disconnect() } + async establishLifecycleLease(): Promise { + if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { + return + } + // Why: an authenticated pair cancels the launch-adoption watchdog and gives + // a never-used adapter authority to retire its empty daemon during clean quit. + await this.client.ensureConnected() + } + // Why: for in-process daemon mode, disconnect without flushing history. // dispose() writes endedAt for all sessions, which would prevent cold // restore. disconnectOnly() leaves history files in unclean state so @@ -985,6 +999,8 @@ export class DaemonPtyAdapter implements IPtyProvider { // We write a final checkpoint before disconnecting so that if the daemon // later crashes while Orca is closed, checkpoint.json has recovery data. async disconnectOnly(): Promise { + this.respawnAdoptionClosed = true + this.releasePendingRespawnAdoptionLease() this.stopCheckpointTimer() // Why: wait for any in-flight timer pass to finish before starting // the final checkpoint. Otherwise both passes race on the shared tmp @@ -1011,11 +1027,31 @@ export class DaemonPtyAdapter implements IPtyProvider { this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() this.removeEventListener = null + if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + try { + // Why: only the authenticated daemon can atomically prove it is empty; + // one shared budget keeps a first connection plus retirement off quit's critical path. + const deadlineMs = Date.now() + 250 + if (!this.client.isConnected()) { + await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) + } + await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now())) + } catch { + // An unreachable daemon falls back to event-driven retirement when its + // authenticated sockets close and it can prove itself empty. + } + } this.client.disconnect() } private async ensureConnected(): Promise { - await this.client.ensureConnected() + try { + await this.client.ensureConnected() + } finally { + // Why: a respawn launcher holds a temporary full pair until this adapter + // has attempted its permanent reconnect, preventing both gaps and leaks. + this.releasePendingRespawnAdoptionLease() + } // Why sampled before setupEventRouting: routing is (re)installed exactly // once per connection, so "no listener yet" identifies a fresh connect — // the only time the daemon-side backgrounded set needs a resync (it is @@ -1298,7 +1334,15 @@ export class DaemonPtyAdapter implements IPtyProvider { try { return await fn() } catch (err) { - if (!this.respawnFn || !isDaemonGoneError(err)) { + // Why: self-retirement removes the token only after an authenticated + // endpoint dropped; an initial missing token may still hide a live daemon. + const missingRetiredEndpointToken = + isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect() + if ( + this.respawnAdoptionClosed || + !this.respawnFn || + (!isDaemonGoneError(err) && !missingRetiredEndpointToken) + ) { throw err } if (!this.respawnPromise) { @@ -1307,7 +1351,13 @@ export class DaemonPtyAdapter implements IPtyProvider { }) } await this.respawnPromise - return await fn() + try { + return await fn() + } finally { + // Why: the retried operation may reject before it reaches a connection + // attempt (for example, a tombstone racing respawn). + this.releasePendingRespawnAdoptionLease() + } } } @@ -1371,7 +1421,20 @@ export class DaemonPtyAdapter implements IPtyProvider { this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() - await this.respawnFn!() + const releaseAdoptionLease = await this.respawnFn!() + if (this.respawnAdoptionClosed) { + // Why: app teardown may win while the launcher is still acquiring its + // temporary pair; a late result must not reinstall a lease nobody owns. + releaseAdoptionLease?.() + throw new Error('Daemon adapter closed during respawn') + } + this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null + } + + private releasePendingRespawnAdoptionLease(): void { + const release = this.pendingRespawnAdoptionRelease + this.pendingRespawnAdoptionRelease = null + release?.() } private setupEventRouting(): void { @@ -1476,5 +1539,18 @@ function isDaemonGoneError(err: unknown): boolean { return true } const msg = err.message - return msg === 'Connection lost' || msg === 'Not connected' || msg === 'Hello response timed out' + return ( + msg === 'Connection lost' || + msg === 'Not connected' || + msg === 'Hello response timed out' || + msg === 'Daemon temporarily unavailable; reconnect' + ) +} + +function isMissingTokenFileError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + return errno.code === 'ENOENT' && errno.syscall === 'open' } diff --git a/src/main/daemon/daemon-self-retirement-respawn.test.ts b/src/main/daemon/daemon-self-retirement-respawn.test.ts new file mode 100644 index 000000000..a71842721 --- /dev/null +++ b/src/main/daemon/daemon-self-retirement-respawn.test.ts @@ -0,0 +1,228 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import type { SubprocessHandle } from './session' + +function fixtureSubprocess(): SubprocessHandle { + let onExit: ((code: number) => void) | null = null + return { + pid: process.pid, + getForegroundProcess: () => null, + write: () => {}, + resize: () => {}, + kill: () => queueMicrotask(() => onExit?.(0)), + forceKill: () => queueMicrotask(() => onExit?.(137)), + signal: () => {}, + onData: () => {}, + onExit: (callback) => { + onExit = callback + }, + dispose: () => {} + } +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000 + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for daemon disconnect') + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +describe('daemon self-retirement respawn', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer | null + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-retirement-respawn-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + server = null + }) + + afterEach(async () => { + await server?.shutdown().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + }) + + async function startServer(): Promise { + const next = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => fixtureSubprocess() + }) + await next.start() + server = next + return next + } + + it('coalesces respawn after an authenticated endpoint removes its token', async () => { + const original = await startServer() + const respawn = vi.fn(async () => { + await startServer() + }) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean } + } + ).client + + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + + await Promise.all([ + adapter.spawn({ sessionId: 'first', cols: 80, rows: 24 }), + adapter.spawn({ sessionId: 'second', cols: 80, rows: 24 }) + ]) + + expect(respawn).toHaveBeenCalledTimes(1) + adapter.dispose() + }) + + it('releases the temporary respawn lease before clean retirement', async () => { + const original = await startServer() + let temporaryAdapter: DaemonPtyAdapter | null = null + const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose()) + const respawn = vi.fn(async () => { + await startServer() + temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + await temporaryAdapter.establishLifecycleLease() + return releaseTemporaryLease + }) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean } + } + ).client + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + + await adapter.spawn({ sessionId: 'respawned', cols: 80, rows: 24 }) + expect(releaseTemporaryLease).toHaveBeenCalledOnce() + await adapter.shutdown('respawned', { immediate: true }) + await adapter.disconnectOnly() + + await waitFor(() => !existsSync(tokenPath)) + }) + + it('releases a temporary lease when a tombstone wins the respawn race', async () => { + const original = await startServer() + let temporaryAdapter: DaemonPtyAdapter | null = null + const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose()) + let adapter!: DaemonPtyAdapter + const respawn = vi.fn(async () => { + await startServer() + temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + await temporaryAdapter.establishLifecycleLease() + const tombstones = (adapter as unknown as { killedSessionTombstones: Map }) + .killedSessionTombstones + tombstones.set('closed-during-respawn', Date.now()) + return releaseTemporaryLease + }) + adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean } + } + ).client + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + + await expect( + adapter.spawn({ sessionId: 'closed-during-respawn', cols: 80, rows: 24 }) + ).rejects.toThrow('was explicitly killed') + + expect(releaseTemporaryLease).toHaveBeenCalledOnce() + adapter.dispose() + }) + + it('releases a lease returned after disposal and does not reconnect', async () => { + const original = await startServer() + let temporaryAdapter: DaemonPtyAdapter | null = null + const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose()) + let returnRespawnLease!: () => void + const respawn = vi.fn(async () => { + await startServer() + temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + await temporaryAdapter.establishLifecycleLease() + await new Promise((resolve) => { + returnRespawnLease = resolve + }) + return releaseTemporaryLease + }) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean; isConnected(): boolean } + } + ).client + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + + const spawn = adapter.spawn({ sessionId: 'disposed', cols: 80, rows: 24 }) + await waitFor(() => returnRespawnLease !== undefined) + adapter.dispose() + returnRespawnLease() + + await expect(spawn).rejects.toThrow('closed during respawn') + expect(releaseTemporaryLease).toHaveBeenCalledOnce() + expect(client.isConnected()).toBe(false) + }) + + it('does not start a respawn after disposal rejects an in-flight operation', async () => { + await startServer() + const respawn = vi.fn(async () => {}) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.establishLifecycleLease() + let rejectCreate!: (error: Error) => void + const client = ( + adapter as unknown as { + client: { + request: (method: string, params: unknown) => Promise + } + } + ).client + vi.spyOn(client, 'request').mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectCreate = reject + }) + ) + + const spawn = adapter.spawn({ sessionId: 'disposed-in-flight', cols: 80, rows: 24 }) + await waitFor(() => rejectCreate !== undefined) + adapter.dispose() + rejectCreate(new Error('Connection lost')) + + await expect(spawn).rejects.toThrow('Connection lost') + expect(respawn).not.toHaveBeenCalled() + }) + + it('does not treat an initial missing token as respawn authority', async () => { + const respawn = vi.fn(async () => {}) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + + await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toMatchObject( + { + code: 'ENOENT' + } + ) + + expect(respawn).not.toHaveBeenCalled() + adapter.dispose() + }) +}) diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 40a624103..aedf2384e 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -2,13 +2,13 @@ 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 { existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonServer } from './daemon-server' import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' import { PROTOCOL_VERSION, type DaemonRequest } from './types' import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' +import { getDaemonPidPath, getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner' const confirmForegroundProcessMock = vi.fn(async () => 'droid') @@ -58,6 +58,7 @@ type DaemonServerPrivate = { clientId: string controlSocket: Socket streamSocket: Socket | null + authenticatedPairEstablished: boolean } > routeRequest(clientId: string, request: DaemonRequest): Promise @@ -67,6 +68,7 @@ describe('DaemonServer', () => { let dir: string let socketPath: string let tokenPath: string + let pidPath: string let server: DaemonServer let client: DaemonClient @@ -75,6 +77,7 @@ describe('DaemonServer', () => { dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') + pidPath = getDaemonPidPath(dir) }) afterEach(async () => { @@ -83,10 +86,11 @@ describe('DaemonServer', () => { rmSync(dir, { recursive: true, force: true }) }) - async function startServer(): Promise { + async function startServer(launchNonce?: string): Promise { server = new DaemonServer({ socketPath, tokenPath, + ...(launchNonce ? { pidPath, launchNonce } : {}), spawnSubprocess: () => createMockSubprocess() }) await server.start() @@ -290,12 +294,12 @@ describe('DaemonServer', () => { spawnSubprocess }) await server.start() - const daemon = server as unknown as DaemonServerPrivate + const c = await connectClient() - const create = daemon.routeRequest('shutdown-client', { - id: 'shutdown-create', - type: 'createOrAttach', - payload: { sessionId: 'shutdown-pending', cols: 80, rows: 24 } + const create = c.request('createOrAttach', { + sessionId: 'shutdown-pending', + cols: 80, + rows: 24 }) const canceledCreate = expect(create).rejects.toThrow( 'Attach canceled for session shutdown-pending' @@ -535,7 +539,8 @@ describe('DaemonServer', () => { daemon.clients.set('client-1', { clientId: 'client-1', controlSocket, - streamSocket + streamSocket, + authenticatedPairEstablished: true }) await daemon.routeRequest('client-1', { @@ -591,7 +596,8 @@ describe('DaemonServer', () => { daemon.clients.set('client-1', { clientId: 'client-1', controlSocket, - streamSocket + streamSocket, + authenticatedPairEstablished: true }) await daemon.routeRequest('client-1', { @@ -648,7 +654,8 @@ describe('DaemonServer', () => { daemon.clients.set('client-1', { clientId: 'client-1', controlSocket, - streamSocket + streamSocket, + authenticatedPairEstablished: true }) await daemon.routeRequest('client-1', { id: 'req-1', @@ -780,6 +787,68 @@ describe('DaemonServer', () => { }) describe('shutdown', () => { + it('waits for the ordinary shutdown reply write before destroying resources', async () => { + await startServer() + const c = await connectClient() + const daemon = server as unknown as DaemonServerPrivate & { + host: { dispose: () => Promise } + } + const controlSocket = [...daemon.clients.values()][0].controlSocket + const originalWrite = controlSocket.write.bind(controlSocket) + let replyFlushed: (() => void) | undefined + vi.spyOn(controlSocket, 'write').mockImplementation((( + chunk: string | Uint8Array, + ...args: unknown[] + ) => { + replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined + return originalWrite(chunk) + }) as unknown as Socket['write']) + const dispose = vi.spyOn(daemon.host, 'dispose') + + await expect(c.request('shutdown', { killSessions: false })).resolves.toEqual({}) + expect(dispose).not.toHaveBeenCalled() + expect(existsSync(tokenPath)).toBe(true) + + replyFlushed?.() + await waitFor(() => !existsSync(tokenPath)) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('removes only its owned token and PID record', async () => { + const launchNonce = 'ordinary-shutdown' + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce }) + ) + await startServer(launchNonce) + + await server.shutdown() + + expect(existsSync(tokenPath)).toBe(false) + expect(existsSync(pidPath)).toBe(false) + }) + + it('preserves token and PID artifacts replaced before ordinary cleanup', async () => { + await startServer('mine') + writeFileSync(tokenPath, 'replacement-token') + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: process.pid, + startedAtMs: null, + launchNonce: 'replacement' + }) + ) + + await server.shutdown() + + expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token') + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({ + pid: process.pid, + launchNonce: 'replacement' + }) + }) + it('stops accepting connections after shutdown', async () => { await startServer() await server.shutdown() @@ -800,9 +869,7 @@ describe('DaemonServer', () => { ) 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 expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({}) await waitFor(() => daemon.server === null) await waitFor(() => !existsSync(socketPath)) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index c5d6c04df..15ec9fecd 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -4,7 +4,7 @@ import { createServer, type Server, type Socket } from 'node:net' import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' -import { writeFileSync, chmodSync, unlinkSync } from 'node:fs' +import { writeFileSync, chmodSync } from 'node:fs' import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' @@ -23,7 +23,9 @@ import type { SubprocessHandle } from './session' import { checkPtySpawnHealth } from './pty-subprocess' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' import { isTuiAgent } from '../../shared/tui-agent-config' +import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' import { + CLEAN_DISCONNECT_PROTOCOL_VERSION, PROTOCOL_VERSION, NOTIFY_PREFIX, SessionNotFoundError, @@ -35,6 +37,21 @@ import { export type DaemonServerOptions = { socketPath: string tokenPath: string + pidPath?: string + launchNonce?: string + startedAtMs?: number + /** Direct-construction seam for protocol fixture tests; production never overrides it. */ + protocolVersion?: number + onIdleShutdown?: () => void + /** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */ + initialAdoptionTestConfig?: { + timeoutMs: number + clock: { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number + } + } ptySpawnHealthCheck?: () => Promise preparePtySpawn?: () => Promise log?: DaemonFileLog @@ -53,21 +70,46 @@ type ConnectedClient = { clientId: string controlSocket: Socket streamSocket: Socket | null + authenticatedPairEstablished: boolean } type PendingPtySpawnPreparation = { canceled: boolean } +type PendingShutdownReply = { + start: () => void +} + export class DaemonServer { + // Why: a new daemon must survive long enough for its first client pair, but + // a parent crash between launch and adoption must not orphan it forever. + private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 + private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 private server: Server | null = null private token: string private host: TerminalHost private socketPath: string private tokenPath: string + private pidPath: string | null + private launchNonce: string | null + private startedAtMs: number | null + private protocolVersion: number + private onIdleShutdown: () => void private ptySpawnHealthCheck: () => Promise private preparePtySpawn: () => Promise private log: DaemonFileLog + private transportSockets = new Set() + private createOrAttachInFlight = 0 + private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' + private initialAdoptionTimer: unknown | null = null + private initialAdoptionDeadlineMs: number | null = null + private retirementRequested = false + private shutdownPromise: Promise | null = null + private ordinaryShutdownServerClose: Promise | null = null + private pendingShutdownReplies = new Map() + private initialAdoptionTimeoutMs: number + private lifecycleClock: NonNullable['clock'] private clients = new Map() private streamDataBatcher = new DaemonStreamDataBatcher( @@ -116,6 +158,28 @@ export class DaemonServer { constructor(opts: DaemonServerOptions) { this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath + this.pidPath = opts.pidPath ?? null + this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.launchNonce = + opts.launchNonce ?? + (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null) + this.startedAtMs = + opts.startedAtMs ?? + (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION + ? Date.now() - process.uptime() * 1000 + : null) + this.onIdleShutdown = opts.onIdleShutdown ?? (() => {}) + this.initialAdoptionTimeoutMs = + opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS + this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? { + setTimeout: (callback, delayMs) => { + const timer = setTimeout(callback, delayMs) + timer.unref() + return timer + }, + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + now: () => Date.now() + } this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth @@ -150,12 +214,48 @@ export class DaemonServer { } catch { // Best-effort on platforms that support it } + if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + // Why: a parent crash before the first full client pair must not leave + // a freshly published, empty daemon alive forever. + this.armInitialAdoptionTimeout() + } resolve() }) }) } async shutdown(): Promise { + if (this.shutdownPromise) { + return this.shutdownPromise + } + const serverClose = this.beginOrdinaryShutdownFence() + this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + return this.shutdownPromise + } + + private beginOrdinaryShutdownFence(): Promise { + this.idleShutdownState = 'shutting-down' + this.cancelInitialAdoptionTimer() + this.ordinaryShutdownServerClose ??= this.beginServerClose() + return this.ordinaryShutdownServerClose + } + + private async finishOrdinaryShutdown(serverClose: Promise): Promise { + this.unlinkOwnedEndpointArtifacts() + await this.disposeDaemonResources() + await serverClose + } + + private unlinkOwnedEndpointArtifacts(): void { + // Why: close has already fenced this endpoint, but ownership checks still + // prevent a late replacement's canonical token or PID record from removal. + unlinkOwnedDaemonTokenFile(this.tokenPath, this.token) + if (this.pidPath && this.launchNonce) { + unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) + } + } + + private async disposeDaemonResources(): Promise { this.stopStreamBacklogProbe() this.transientFactRelay.dispose() this.cancelAllPendingPtySpawnPreparations() @@ -169,29 +269,142 @@ export class DaemonServer { }) } this.streamDataBatcher.clear() + this.pendingShutdownReplies.clear() for (const [, client] of this.clients) { client.controlSocket.destroy() client.streamSocket?.destroy() } this.clients.clear() + for (const socket of this.transportSockets) { + socket.destroy() + } + this.transportSockets.clear() + } + private beginServerClose(): Promise { + const server = this.server + this.server = null + if (!server) { + return Promise.resolve() + } return new Promise((resolve) => { - if (this.server) { - this.server.close(() => { - try { - unlinkSync(this.socketPath) - } catch {} - resolve() - }) - this.server = null - } else { + // Why: call close synchronously before any awaited cleanup so no new + // transport can enter after the idle fence is proven empty. + server.close(() => { + // Node owns unlinking its Unix listener. An extra check-then-unlink here could + // delete a replacement endpoint installed concurrently after close. resolve() - } + }) }) } + private isIdle(): boolean { + return ( + this.transportSockets.size === 0 && + this.clients.size === 0 && + this.createOrAttachInFlight === 0 && + this.host.listSessions().length === 0 + ) + } + + private reevaluateIdleShutdown(): void { + if (this.idleShutdownState !== 'running') { + return + } + if (this.retirementRequested) { + this.cancelInitialAdoptionTimer() + if (this.isIdle()) { + this.beginIdleShutdown() + } + return + } + if (!this.isIdle() || this.initialAdoptionDeadlineMs === null) { + this.cancelInitialAdoptionTimer() + return + } + if (this.initialAdoptionTimer !== null) { + return + } + const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.lifecycleClock.now()) + if (remainingMs === 0) { + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + return + } + this.initialAdoptionTimer = this.lifecycleClock.setTimeout(() => { + this.initialAdoptionTimer = null + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + }, remainingMs) + } + + private armInitialAdoptionTimeout(): void { + this.initialAdoptionDeadlineMs = this.lifecycleClock.now() + this.initialAdoptionTimeoutMs + this.reevaluateIdleShutdown() + } + + private cancelInitialAdoptionTimer(): void { + if (this.initialAdoptionTimer === null) { + return + } + this.lifecycleClock.clearTimeout(this.initialAdoptionTimer) + this.initialAdoptionTimer = null + } + + private beginIdleShutdown(): void { + this.initialAdoptionTimer = null + if (this.idleShutdownState !== 'running') { + return + } + this.idleShutdownState = 'idle-shutdown-pending' + if (!this.isIdle()) { + // Why: work admitted before the fence wins. Clearing the pending state + // keeps that already-started client/session fully usable. + this.idleShutdownState = 'running' + this.reevaluateIdleShutdown() + return + } + + this.idleShutdownState = 'shutting-down' + // beginServerClose() runs synchronously up to server.close(), before host + // disposal or file cleanup can yield to a racing connection. + const serverClose = this.beginServerClose() + this.shutdownPromise = this.finishIdleShutdown(serverClose) + } + + private async finishIdleShutdown(serverClose: Promise): Promise { + this.unlinkOwnedEndpointArtifacts() + await this.disposeDaemonResources() + await serverClose + this.onIdleShutdown() + } + private handleConnection(socket: Socket): void { + this.cancelInitialAdoptionTimer() + this.transportSockets.add(socket) + const removeTransport = (): void => { + this.transportSockets.delete(socket) + this.reevaluateIdleShutdown() + } + socket.once('close', removeTransport) + socket.on('error', () => socket.destroy()) + + if (this.idleShutdownState !== 'running') { + // Why: an accepted connection queued just before server.close() must get + // an explicit retry signal instead of appearing authenticated then dying. + socket.end( + encodeNdjson({ + type: 'hello', + ok: false, + error: 'Daemon temporarily unavailable; reconnect', + retryable: true + }) + ) + return + } // Why: clients can send multibyte prompt/input text split across socket // chunks; keep UTF-8 sequences intact before NDJSON parsing. const decoder = new StringDecoder('utf8') @@ -203,7 +416,6 @@ export class DaemonServer { ) socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) - socket.on('error', () => socket.destroy()) } private handleFirstMessage( @@ -219,7 +431,7 @@ export class DaemonServer { return } - if (hello.version !== PROTOCOL_VERSION) { + if (hello.version !== this.protocolVersion) { this.log.log('client-hello-rejected', { reason: 'protocol-mismatch', clientVersion: hello.version @@ -237,18 +449,34 @@ export class DaemonServer { } this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) - socket.write(encodeNdjson({ type: 'hello', ok: true })) + socket.write( + encodeNdjson({ + type: 'hello', + ok: true, + ...(this.launchNonce && this.startedAtMs + ? { + daemonIdentity: { + pid: process.pid, + startedAtMs: this.startedAtMs, + launchNonce: this.launchNonce + } + } + : {}) + }) + ) if (hello.role === 'control') { const previous = this.clients.get(hello.clientId) const client: ConnectedClient = { clientId: hello.clientId, controlSocket: socket, - streamSocket: null + streamSocket: null, + authenticatedPairEstablished: false } this.clients.set(hello.clientId, client) this.setupControlSocket(socket, hello.clientId) if (previous) { + this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) // Why: a reconnect can reuse a clientId before the old sockets notice // their close. Tear them down after installing the new owner so stale // close events cannot delete the replacement client entry. @@ -264,6 +492,12 @@ export class DaemonServer { return } this.setupStreamSocket(socket, client) + client.authenticatedPairEstablished = true + // A complete app connection, unlike a health or raw socket probe, owns + // the endpoint again and cancels pending event-driven retirement. + this.initialAdoptionDeadlineMs = null + this.retirementRequested = false + this.cancelInitialAdoptionTimer() } } @@ -285,12 +519,28 @@ export class DaemonServer { if (client?.controlSocket !== socket) { return } + const wasFullyAuthenticated = client.authenticatedPairEstablished this.streamDataBatcher.clear(clientId) client.streamSocket?.destroy() this.clients.delete(clientId) + this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated) + this.reevaluateIdleShutdown() }) } + private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void { + if ( + !wasFullyAuthenticated || + [...this.clients.values()].some((remaining) => remaining.authenticatedPairEstablished) || + this.idleShutdownState !== 'running' + ) { + return + } + // Why: once the last full client is gone, exact daemon-side emptiness is + // sufficient; incomplete transports may block but never erase this request. + this.retirementRequested = true + } + private setupStreamSocket(socket: Socket, client: ConnectedClient): void { const previous = client.streamSocket socket.removeAllListeners('data') @@ -330,7 +580,12 @@ export class DaemonServer { try { const result = await this.routeRequest(clientId, request) if (!isNotify) { - socket.write(encodeNdjson({ id: request.id, ok: true, payload: result })) + const pendingShutdown = this.pendingShutdownReplies.get( + this.shutdownReplyKey(clientId, request.id) + ) + socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => { + pendingShutdown?.start() + }) } } catch (err) { if (!isNotify) { @@ -345,6 +600,41 @@ export class DaemonServer { } } + private shutdownReplyKey(clientId: string, requestId: string): string { + return `${clientId}\u0000${requestId}` + } + + private deferShutdownUntilReply( + clientId: string, + requestId: string, + socket: Socket, + finish: () => Promise + ): void { + const key = this.shutdownReplyKey(clientId, requestId) + let started = false + let timer: ReturnType + const start = (): void => { + if (started) { + return + } + started = true + clearTimeout(timer) + socket.off('close', start) + socket.off('error', start) + this.pendingShutdownReplies.delete(key) + if (!this.shutdownPromise) { + this.shutdownPromise = finish() + } + } + // Why: a non-reading authenticated peer must not pin a fenced daemon by + // holding its acknowledgement behind permanent socket backpressure. + timer = setTimeout(start, DaemonServer.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS) + timer.unref() + socket.once('close', start) + socket.once('error', start) + this.pendingShutdownReplies.set(key, { start }) + } + private async preparePtySpawnUnlessCanceled(sessionId: string): Promise { const preparation: PendingPtySpawnPreparation = { canceled: false } const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set() @@ -387,64 +677,80 @@ export class DaemonServer { switch (request.type) { case 'createOrAttach': { + if (this.idleShutdownState !== 'running') { + throw new Error('Daemon temporarily unavailable; reconnect') + } + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + // Why: a control-only replacement cannot own terminal admission or + // erase the prior full client's monotonic retirement request. + throw new Error('Daemon client connection is incomplete; reconnect') + } + this.createOrAttachInFlight++ const p = request.payload - await this.preparePtySpawnUnlessCanceled(p.sessionId) - const result = await this.host.createOrAttach({ - sessionId: p.sessionId, - cols: p.cols, - rows: p.rows, - cwd: p.cwd, - env: p.env, - envToDelete: p.envToDelete, - command: p.command, - startupCommandDelivery: p.startupCommandDelivery, - // Why: daemon RPC payloads are untrusted JSON. Persist only the - // allowlisted enum used for byte routing, never arbitrary identity. - ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), - shellOverride: p.shellOverride, - terminalWindowsWslDistro: p.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, - shellReadySupported: p.shellReadySupported, - historySeed: p.historySeed, - ...(p.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } - : {}), - streamClient: { - onData: (data) => { - // Scan BEFORE enqueue: the batcher may keep-tail drop this - // chunk, but its facts must be captured regardless. - this.transientFactRelay.onSessionData(p.sessionId, data) - const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId) - const isInteractiveOutput = - data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS - this.streamDataBatcher.enqueue(clientId, p.sessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS - }) - }, - onExit: (code) => { - // Why: exit tears down renderer handlers, so it must ride the - // ordered queue behind final output even when the shallow socket - // gate holds that output for a later drain pass. - this.log.log('session-exited', { sessionId: p.sessionId, code }) - this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { - type: 'event', - event: 'exit', - sessionId: p.sessionId, - payload: { code } - }) - this.streamDataBatcher.flush(clientId) - recordDaemonStreamBacklogEvent('sessionExit', { - sessionIdSuffix: p.sessionId.slice(-10) - }) - this.transientFactRelay.onSessionExit(p.sessionId) - this.streamClientIdBySessionId.delete(p.sessionId) - this.lastInputAtBySessionId.delete(p.sessionId) + let result: Awaited> + try { + await this.preparePtySpawnUnlessCanceled(p.sessionId) + result = await this.host.createOrAttach({ + sessionId: p.sessionId, + cols: p.cols, + rows: p.rows, + cwd: p.cwd, + env: p.env, + envToDelete: p.envToDelete, + command: p.command, + startupCommandDelivery: p.startupCommandDelivery, + // Why: daemon RPC payloads are untrusted JSON. Persist only the + // allowlisted enum used for byte routing, never arbitrary identity. + ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), + shellOverride: p.shellOverride, + terminalWindowsWslDistro: p.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, + shellReadySupported: p.shellReadySupported, + historySeed: p.historySeed, + ...(p.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } + : {}), + streamClient: { + onData: (data) => { + // Scan BEFORE enqueue: the batcher may keep-tail drop this + // chunk, but its facts must be captured regardless. + this.transientFactRelay.onSessionData(p.sessionId, data) + const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId) + const isInteractiveOutput = + data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS + this.streamDataBatcher.enqueue(clientId, p.sessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS + }) + }, + onExit: (code) => { + // Why: exit tears down renderer handlers, so it must ride the + // ordered queue behind final output even when the shallow socket + // gate holds that output for a later drain pass. + this.log.log('session-exited', { sessionId: p.sessionId, code }) + this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + type: 'event', + event: 'exit', + sessionId: p.sessionId, + payload: { code } + }) + this.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: p.sessionId.slice(-10) + }) + this.transientFactRelay.onSessionExit(p.sessionId) + this.streamClientIdBySessionId.delete(p.sessionId) + this.lastInputAtBySessionId.delete(p.sessionId) + this.reevaluateIdleShutdown() + } } - } - }) + }) + } finally { + this.createOrAttachInFlight-- + this.reevaluateIdleShutdown() + } this.streamClientIdBySessionId.set(p.sessionId, clientId) // Why an attach-time marker: the adapter resyncs the background set on // a fresh connection, which can precede this attach — main's scan @@ -599,6 +905,35 @@ export class DaemonServer { case 'listSessions': return { sessions: this.host.listSessions() } + case 'shutdownIfIdle': { + const authenticatedClient = this.clients.get(clientId) + const retiring = + authenticatedClient !== undefined && + authenticatedClient.streamSocket !== null && + this.clients.size === 1 && + this.createOrAttachInFlight === 0 && + this.host.listSessions().length === 0 && + [...this.transportSockets].every( + (transport) => + transport === authenticatedClient.controlSocket || + transport === authenticatedClient.streamSocket + ) + if (!retiring) { + return { retiring: false } + } + this.idleShutdownState = 'shutting-down' + this.initialAdoptionDeadlineMs = null + this.retirementRequested = false + this.cancelInitialAdoptionTimer() + // Why: close before acknowledging retirement so no new terminal can + // race between the empty proof and daemon disposal. + const serverClose = this.beginServerClose() + this.deferShutdownUntilReply(clientId, request.id, authenticatedClient.controlSocket, () => + this.finishIdleShutdown(serverClose) + ) + return { retiring: true } + } + case 'getSnapshot': { const snapshotStart = performance.now() const requestedScrollbackRows = request.payload.scrollbackRows @@ -644,11 +979,12 @@ export class DaemonServer { await this.ptySpawnHealthCheck() return { healthy: true } - case 'shutdown': + case 'shutdown': { this.log.log('shutdown', { reason: 'rpc', killSessions: request.payload.killSessions === true }) + const serverClose = this.beginOrdinaryShutdownFence() if (request.payload.killSessions) { try { await this.host.dispose() @@ -661,8 +997,16 @@ export class DaemonServer { }) } } - process.nextTick(() => this.shutdown()) + const controlSocket = this.clients.get(clientId)?.controlSocket + if (controlSocket) { + this.deferShutdownUntilReply(clientId, request.id, controlSocket, () => + this.finishOrdinaryShutdown(serverClose) + ) + } else if (!this.shutdownPromise) { + this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + } return {} + } } throw new Error(`Unknown request type: ${(request as { type: string }).type}`) } diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index f0367319e..613b9e6a1 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { DaemonSpawner, getDaemonPidPath, getDaemonSocketPath, - getDaemonTokenPath + getDaemonTokenPath, + restoreClaimedDaemonArtifact } from './daemon-spawner' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' @@ -70,6 +71,20 @@ describe('DaemonSpawner', () => { } describe('ensureRunning', () => { + it('passes the scoped PID path and a fresh launch nonce to the launcher', async () => { + const launcher = vi.fn(async () => ({ shutdown: vi.fn(async () => {}) })) + spawner = new DaemonSpawner({ runtimeDir: dir, launcher }) + + await spawner.ensureRunning() + + expect(launcher).toHaveBeenCalledWith( + getDaemonSocketPath(dir), + getDaemonTokenPath(dir), + getDaemonPidPath(dir), + expect.stringMatching(/^[0-9a-f-]{36}$/) + ) + }) + it('uses protocol-scoped socket and token paths', () => { const socketPath = getDaemonSocketPath(dir) const tokenPath = getDaemonTokenPath(dir) @@ -172,3 +187,51 @@ describe('DaemonSpawner', () => { }) }) }) + +describe('restoreClaimedDaemonArtifact', () => { + it('retains the unique claim when restoration fails without a replacement', () => { + expect( + restoreClaimedDaemonArtifact('/claimed', '/canonical', { + copyExclusive: () => { + throw new Error('injected ENOSPC') + }, + canonicalExists: () => false + }) + ).toBe(false) + }) + + it('retains the unique claim when a failed copy leaves a partial canonical file', () => { + const restoreDir = createTestDir() + const canonicalPath = join(restoreDir, 'partial-canonical') + try { + expect( + restoreClaimedDaemonArtifact('/claimed', canonicalPath, { + copyExclusive: () => { + writeFileSync(canonicalPath, 'partial') + throw Object.assign(new Error('injected ENOSPC'), { code: 'ENOSPC' }) + }, + canonicalExists: () => true + }) + ).toBe(false) + } finally { + rmSync(restoreDir, { recursive: true, force: true }) + } + }) + + it('allows claim cleanup after successful restore or a confirmed replacement', () => { + expect( + restoreClaimedDaemonArtifact('/claimed', '/canonical', { + copyExclusive: () => {}, + canonicalExists: () => false + }) + ).toBe(true) + expect( + restoreClaimedDaemonArtifact('/claimed', '/canonical', { + copyExclusive: () => { + throw Object.assign(new Error('injected EEXIST'), { code: 'EEXIST' }) + }, + canonicalExists: () => true + }) + ).toBe(true) + }) +}) diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index ef54ed9a2..ba0ea239c 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -1,4 +1,5 @@ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' +import { constants, copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' @@ -12,14 +13,21 @@ export type DaemonPidFile = { startedAtMs: number | null entryPath?: string appVersion?: string + launchNonce?: string } export type DaemonProcessHandle = { mode?: 'degraded-new-pty-fallback' + releaseAdoptionLease?(): void shutdown(): Promise } -export type DaemonLauncher = (socketPath: string, tokenPath: string) => Promise +export type DaemonLauncher = ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string +) => Promise export type DaemonSpawnerOptions = { runtimeDir: string @@ -32,12 +40,14 @@ export class DaemonSpawner { private handle: DaemonProcessHandle | null = null private socketPath: string private tokenPath: string + private pidPath: string constructor(opts: DaemonSpawnerOptions) { this.runtimeDir = opts.runtimeDir this.launcher = opts.launcher this.socketPath = getDaemonSocketPath(this.runtimeDir) this.tokenPath = getDaemonTokenPath(this.runtimeDir) + this.pidPath = getDaemonPidPath(this.runtimeDir) } async ensureRunning(): Promise { @@ -45,7 +55,9 @@ export class DaemonSpawner { return { socketPath: this.socketPath, tokenPath: this.tokenPath } } - this.handle = await this.launcher(this.socketPath, this.tokenPath) + // Why: a detached daemon may clean up after its parent exits. A unique + // launch identity keeps it from deleting a replacement daemon's PID file. + this.handle = await this.launcher(this.socketPath, this.tokenPath, this.pidPath, randomUUID()) return { socketPath: this.socketPath, tokenPath: this.tokenPath } } @@ -96,3 +108,83 @@ export function getDaemonPidPath(runtimeDir: string, protocolVersion = PROTOCOL_ export function serializeDaemonPidFile(pidFile: DaemonPidFile): string { return JSON.stringify(pidFile) } + +export function unlinkOwnedDaemonPidFile( + pidPath: string, + expectedPid: number, + expectedLaunchNonce: string +): boolean { + return claimAndUnlinkOwnedFile(pidPath, (content) => { + try { + const parsed = JSON.parse(content) as { pid?: unknown; launchNonce?: unknown } + return parsed.pid === expectedPid && parsed.launchNonce === expectedLaunchNonce + } catch { + return false + } + }) +} + +export function unlinkOwnedDaemonTokenFile(tokenPath: string, expectedToken: string): boolean { + return claimAndUnlinkOwnedFile(tokenPath, (content) => content.trim() === expectedToken) +} + +function claimAndUnlinkOwnedFile( + filePath: string, + ownsContent: (content: string) => boolean +): boolean { + const claimedPath = `${filePath}.cleanup-${process.pid}-${randomUUID()}` + try { + // Why: rename claims one exact directory entry before inspection, so a replacement + // installed afterward stays at the canonical path and cannot be unlinked by us. + renameSync(filePath, claimedPath) + } catch { + return false + } + try { + if (ownsContent(readFileSync(claimedPath, 'utf8'))) { + unlinkSync(claimedPath) + return true + } + } catch { + // Restore below when the claimed file cannot be validated as ours. + } + + const restoredOrReplaced = restoreClaimedDaemonArtifact(claimedPath, filePath) + if (restoredOrReplaced) { + try { + unlinkSync(claimedPath) + } catch { + // A uniquely named unowned claim is safer to leave than overwriting a replacement. + } + } + return false +} + +export function restoreClaimedDaemonArtifact( + claimedPath: string, + canonicalPath: string, + operations: { + copyExclusive?: (source: string, target: string) => void + canonicalExists?: (path: string) => boolean + } = {} +): boolean { + const copyExclusive = + operations.copyExclusive ?? + ((source: string, target: string) => copyFileSync(source, target, constants.COPYFILE_EXCL)) + const canonicalExists = operations.canonicalExists ?? existsSync + try { + // Why: exclusive restore never overwrites a newer canonical replacement. + copyExclusive(claimedPath, canonicalPath) + return true + } catch (error) { + // Why: copy failures can leave a partial canonical file. Only EEXIST proves + // another owner had already installed a replacement before our copy. + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EEXIST' && + canonicalExists(canonicalPath) + ) + } +} diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 59b5c998f..12edb3b8a 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createProductionLauncher } from './production-launcher' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' @@ -63,6 +63,20 @@ describe('createProductionLauncher', () => { expect(typeof launcher).toBe('function') }) + it('rejects either ownership argument without its pair before forking', async () => { + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => '/fake/path.js' + }) + + await expect( + launcher(socketPathFor(dir), tokenPathFor(dir), join(dir, 'daemon.pid')) + ).rejects.toThrow('provided together') + await expect( + launcher(socketPathFor(dir), tokenPathFor(dir), undefined, 'launch-a') + ).rejects.toThrow('provided together') + expect(forkMock).not.toHaveBeenCalled() + }) + it('can be used with DaemonSpawner (in-process fallback)', async () => { // Use in-process launcher for testing (same as DaemonSpawner tests) const launcher = async (socketPath: string, tokenPath: string) => { @@ -115,8 +129,9 @@ describe('createProductionLauncher', () => { getDaemonEntryPath: () => join(dir, 'daemon-entry.js') }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready' }) + const pidPath = join(dir, 'daemon.pid') + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a') + handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) const handle = await launch expect(handle.shutdown).toEqual(expect.any(Function)) @@ -125,9 +140,73 @@ describe('createProductionLauncher', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalled() expect(child.unref).toHaveBeenCalled() + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({ + pid: 12345, + startedAtMs: 123_456, + entryPath: join(dir, 'daemon-entry.js'), + launchNonce: 'launch-a' + }) + expect(forkMock).toHaveBeenCalledWith( + join(dir, 'daemon-entry.js'), + expect.arrayContaining(['--pid-record', pidPath, '--launch-nonce', 'launch-a']), + expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) + ) }) - it('removes shutdown exit listener when force-kill timeout settles first', async () => { + it('resolves shutdown only after observing child exit', async () => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + on: vi.fn((event: string, callback: (arg?: unknown) => void) => { + handlers[event]?.push(callback) + return child + }), + once: vi.fn((event: string, callback: (arg?: unknown) => void) => { + handlers[event]?.push(callback) + return child + }), + off: vi.fn((event: string, callback: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return child + }), + kill: vi.fn((signal: NodeJS.Signals) => { + if (signal === 'SIGTERM') { + queueMicrotask(() => { + child.exitCode = 0 + for (const callback of handlers.exit.slice()) { + callback(0) + } + }) + } + return true + }), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + }) + const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) + handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) + const handle = await launch + + await expect(handle.shutdown()).resolves.toBeUndefined() + + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + expect(child.exitCode).toBe(0) + }) + + it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => { vi.useFakeTimers() try { const handlers: Record void)[]> = { @@ -138,6 +217,9 @@ describe('createProductionLauncher', () => { const child = { pid: 12345, killed: false, + connected: false, + exitCode: null, + signalCode: null, on: vi.fn((event: string, cb: (arg?: unknown) => void) => { handlers[event]?.push(cb) return child @@ -150,8 +232,10 @@ describe('createProductionLauncher', () => { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return child }), - kill: vi.fn(), - disconnect: vi.fn(), + kill: vi.fn(() => true), + disconnect: vi.fn(() => { + child.connected = false + }), unref: vi.fn() } forkMock.mockReturnValueOnce(child) @@ -161,22 +245,135 @@ describe('createProductionLauncher', () => { }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready' }) + handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) const handle = await launch - const shutdown = handle.shutdown() + const shutdown = expect(handle.shutdown()).rejects.toThrow( + 'Daemon did not exit after SIGKILL' + ) expect(handlers.exit).toHaveLength(1) - await vi.advanceTimersByTimeAsync(5000) + await vi.advanceTimersByTimeAsync(6000) await shutdown expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM') expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') expect(handlers.exit).toHaveLength(0) + expect(child.unref).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } }) + + it('preserves readiness and signaling failures while releasing startup IPC', async () => { + vi.useFakeTimers() + try { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const signalError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + once: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + kill: vi.fn(() => { + throw signalError + }), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + }) + const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) + handlers.message[0]?.({ type: 'ready' }) + + const error = await launch.catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + signalError + ]) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + expect(handlers.message).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + expect(handlers.exit).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('preserves PID publication and cleanup failures', async () => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const signalError = Object.assign(new Error('signal blocked'), { code: 'EPERM' }) + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + once: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + kill: vi.fn(() => { + throw signalError + }), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const pidPath = join(dir, 'occupied.pid') + writeFileSync(pidPath, 'occupied') + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + }) + + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') + handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) + + const error = await launch.catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ code: 'EEXIST' }), + signalError + ]) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + }) }) function socketPathFor(dir: string): string { diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index 3fb5d7e79..b9fe704a4 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -1,5 +1,10 @@ import { fork, type ChildProcess } from 'node:child_process' -import type { DaemonLauncher, DaemonProcessHandle } from './daemon-spawner' +import { writeFileSync } from 'node:fs' +import { + serializeDaemonPidFile, + type DaemonLauncher, + type DaemonProcessHandle +} from './daemon-spawner' const READY_TIMEOUT_MS = 10_000 @@ -8,17 +13,63 @@ export type ProductionLauncherOptions = { } export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher { - return async (socketPath: string, tokenPath: string): Promise => { + return async ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ): Promise => { + if ((pidPath === undefined) !== (launchNonce === undefined)) { + // Why: partial ownership metadata would launch a v24 daemon that cannot + // prove which PID record it may remove during self-retirement. + throw new Error('Daemon PID path and launch nonce must be provided together') + } const entryPath = opts.getDaemonEntryPath() - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - detached: true, - env: { ...process.env }, - ...(process.platform === 'win32' ? { windowsHide: true } : {}) - }) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + ...(pidPath && launchNonce ? ['--pid-record', pidPath, '--launch-nonce', launchNonce] : []) + ], + { + // Why: detached daemon output is not consumed; ignored streams cannot + // keep Electron alive after the child and IPC channel are unreferenced. + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + detached: true, + env: { ...process.env }, + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + } + ) - await waitForReady(child) + let startedAtMs: number + try { + startedAtMs = await waitForReady(child) + } catch (error) { + return rejectAfterChildCleanup(child, error) + } + if (pidPath && launchNonce) { + if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { + return rejectAfterChildCleanup(child, new Error('Daemon readiness identity is incomplete')) + } + try { + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: child.pid as number, + startedAtMs, + entryPath, + launchNonce + }), + { mode: 0o600, flag: 'wx' } + ) + } catch (error) { + return rejectAfterChildCleanup(child, error) + } + } // Unref so the Electron process can exit without waiting for the daemon child.unref() @@ -30,7 +81,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } } -function waitForReady(child: ChildProcess): Promise { +function waitForReady(child: ChildProcess): Promise { return new Promise((resolve, reject) => { let timeout: ReturnType | undefined let settled = false @@ -42,15 +93,12 @@ function waitForReady(child: ChildProcess): Promise { child.off('error', onError) child.off('exit', onExit) } - function fail(error: Error, killChild = false): void { + function fail(error: Error): void { if (settled) { return } settled = true cleanupStartupListeners() - if (killChild) { - child.kill('SIGTERM') - } reject(error) } function onMessage(msg: unknown): void { @@ -58,11 +106,16 @@ function waitForReady(child: ChildProcess): Promise { if (settled) { return } + const startedAtMs = (msg as { startedAtMs?: unknown }).startedAtMs + if (typeof startedAtMs !== 'number' || !Number.isFinite(startedAtMs) || startedAtMs <= 0) { + fail(new Error('Daemon readiness identity is incomplete')) + return + } settled = true // Why: the daemon is detached after readiness, so startup listeners // must not keep the child process closure alive for the daemon lifetime. cleanupStartupListeners() - resolve() + resolve(startedAtMs) } } function onError(err: Error): void { @@ -73,7 +126,7 @@ function waitForReady(child: ChildProcess): Promise { } timeout = setTimeout(() => { - fail(new Error('Daemon failed to signal readiness within timeout'), true) + fail(new Error('Daemon failed to signal readiness within timeout')) }, READY_TIMEOUT_MS) child.on('message', onMessage) @@ -82,35 +135,88 @@ function waitForReady(child: ChildProcess): Promise { }) } -function shutdownChild(child: ChildProcess): Promise { - return new Promise((resolve) => { - if (child.killed) { - resolve() +async function shutdownChild(child: ChildProcess): Promise { + try { + if ( + (child.exitCode !== null && child.exitCode !== undefined) || + (child.signalCode !== null && child.signalCode !== undefined) + ) { return } - - let settled = false - let timeout: ReturnType - function finish(): void { - if (settled) { - return + await new Promise((resolve, reject) => { + let settled = false + let timeout: ReturnType + let forceTimeout: ReturnType | undefined + function finish(error?: unknown): void { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (forceTimeout) { + clearTimeout(forceTimeout) + } + child.off('exit', onExit) + if (error) { + reject(error) + } else { + resolve() + } } - settled = true - clearTimeout(timeout) - child.off('exit', onExit) - resolve() + + function onExit(): void { + finish() + } + + timeout = setTimeout(() => { + try { + if (child.kill('SIGKILL') === false) { + finish(new Error('Failed to deliver SIGKILL to daemon')) + return + } + } catch (error) { + finish(isNoSuchProcessError(error) ? undefined : error) + return + } + // Why: signal delivery is not process exit; keep the listener for one + // bounded interval before releasing launcher-owned handles. + if (!settled) { + forceTimeout = setTimeout( + () => finish(new Error('Daemon did not exit after SIGKILL')), + 1000 + ) + } + }, 5000) + + child.once('exit', onExit) + try { + if (child.kill('SIGTERM') === false) { + finish(new Error('Failed to deliver SIGTERM to daemon')) + } + } catch (error) { + finish(isNoSuchProcessError(error) ? undefined : error) + } + }) + } finally { + if (child.connected) { + child.disconnect() } - - function onExit(): void { - finish() - } - - timeout = setTimeout(() => { - child.kill('SIGKILL') - finish() - }, 5000) - - child.once('exit', onExit) - child.kill('SIGTERM') - }) + child.unref() + } +} + +async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown): Promise { + try { + await shutdownChild(child) + } catch (cleanupError) { + throw new AggregateError( + [launchError, cleanupError], + 'Daemon launch and child cleanup both failed' + ) + } + throw launchError +} + +function isNoSuchProcessError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 63c5edc57..0daaf0ac0 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -16,10 +16,11 @@ import type { TuiAgent } from '../../shared/types' // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 23 +export const PROTOCOL_VERSION = 24 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 +export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -80,19 +81,7 @@ export type { TerminalCheckpointFile } from './daemon-checkpoint-file' // ─── NDJSON Protocol Messages ─────────────────────────────────────── // Hello handshake (first message on each socket) -export type HelloMessage = { - type: 'hello' - version: number - token: string - clientId: string - role: 'control' | 'stream' -} - -export type HelloResponse = { - type: 'hello' - ok: boolean - error?: string -} +export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daemon-hello-protocol' // ─── RPC Requests (Client → Daemon, on control socket) ───────────── @@ -211,6 +200,11 @@ export type ListSessionsRequest = { type: 'listSessions' } +export type ShutdownIfIdleRequest = { + id: string + type: 'shutdownIfIdle' +} + export type DetachRequest = { id: string type: 'detach' @@ -330,6 +324,7 @@ export type DaemonRequest = | KillRequest | SignalRequest | ListSessionsRequest + | ShutdownIfIdleRequest | DetachRequest | GetCwdRequest | GetForegroundProcessRequest @@ -368,6 +363,10 @@ export type ListSessionsResult = { sessions: SessionInfo[] } +export type ShutdownIfIdleResult = { + retiring: boolean +} + export type SystemResolverHealth = 'healthy' | 'unhealthy' | 'unknown' export type SystemResolverHealthResult = { diff --git a/tests/e2e/daemon-lifecycle-retirement.spec.ts b/tests/e2e/daemon-lifecycle-retirement.spec.ts new file mode 100644 index 000000000..f52208899 --- /dev/null +++ b/tests/e2e/daemon-lifecycle-retirement.spec.ts @@ -0,0 +1,241 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { build } from 'esbuild' +import { expect, test } from '@playwright/test' +import { DaemonClient } from '../../src/main/daemon/client' +import { DaemonPtyAdapter } from '../../src/main/daemon/daemon-pty-adapter' +import { + getDaemonPidPath, + getDaemonSocketPath, + getDaemonTokenPath +} from '../../src/main/daemon/daemon-spawner' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' + +type FixtureDaemon = { + child: ChildProcess + protocolVersion: number + socketPath: string + tokenPath: string + pidPath: string +} + +async function waitFor( + label: string, + predicate: () => boolean | Promise, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!(await predicate())) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${label}`) + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } +} + +async function launchFixture( + entryPath: string, + daemonDir: string, + protocolVersion: number +): Promise { + const socketPath = getDaemonSocketPath(daemonDir, protocolVersion) + const tokenPath = getDaemonTokenPath(daemonDir, protocolVersion) + const pidPath = getDaemonPidPath(daemonDir, protocolVersion) + const launchNonce = randomUUID() + const child = fork( + entryPath, + [ + '--protocol', + String(protocolVersion), + '--socket', + socketPath, + '--token', + tokenPath, + ...(protocolVersion >= PROTOCOL_VERSION + ? ['--pid-record', pidPath, '--launch-nonce', launchNonce] + : []) + ], + { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { + ...process.env, + NODE_PATH: [path.join(process.cwd(), 'node_modules'), process.env.NODE_PATH] + .filter(Boolean) + .join(path.delimiter) + } + } + ) + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr = `${stderr}${String(chunk)}`.slice(-8_192) + }) + try { + await new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timeout) + child.off('message', onMessage) + child.off('error', onError) + child.off('exit', onExit) + } + const onMessage = (message: unknown): void => { + if ((message as { type?: unknown }).type !== 'ready') { + return + } + cleanup() + resolve() + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const onExit = (code: number | null): void => { + cleanup() + reject(new Error(`Lifecycle fixture exited with ${code}: ${stderr.trim()}`)) + } + const timeout = setTimeout(() => { + cleanup() + reject(new Error('Lifecycle fixture startup timed out')) + }, 10_000) + child.on('message', onMessage) + child.on('error', onError) + child.on('exit', onExit) + }) + } catch (error) { + try { + await stopChild(child, `fixture v${protocolVersion}`) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Fixture startup and cleanup both failed') + } + throw error + } + return { child, protocolVersion, socketPath, tokenPath, pidPath } +} + +async function stopChild(child: ChildProcess, label: string): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + child.kill('SIGTERM') + try { + await waitFor( + `${label} graceful exit`, + () => child.exitCode !== null || child.signalCode !== null, + 3_000 + ) + return + } catch { + child.kill('SIGKILL') + } + await waitFor( + `${label} forced exit`, + () => child.exitCode !== null || child.signalCode !== null, + 3_000 + ) +} + +async function stopFixture(fixture: FixtureDaemon): Promise { + await stopChild(fixture.child, `fixture v${fixture.protocolVersion}`) +} + +test('v22 stays reattachable while v24 retires after its last empty client disconnects', async () => { + const rootDir = mkdtempSync(path.join(tmpdir(), 'orca-daemon-lifecycle-')) + const daemonDir = path.join(rootDir, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + const entryPath = path.join(rootDir, 'daemon-lifecycle-entry.cjs') + const fixtures: FixtureDaemon[] = [] + let testError: unknown + + try { + await build({ + entryPoints: [path.join(process.cwd(), 'tests/e2e/fixtures/daemon-lifecycle-entry.ts')], + outfile: entryPath, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + external: ['node-pty'], + logLevel: 'silent' + }) + + const legacy = await launchFixture(entryPath, daemonDir, 22) + fixtures.push(legacy) + const legacyClient = new DaemonClient({ + socketPath: legacy.socketPath, + tokenPath: legacy.tokenPath, + protocolVersion: 22 + }) + await legacyClient.ensureConnected() + await expect( + legacyClient.request('createOrAttach', { sessionId: 'legacy-live', cols: 80, rows: 24 }) + ).resolves.toMatchObject({ isNew: true }) + legacyClient.disconnect() + + const current = await launchFixture(entryPath, daemonDir, PROTOCOL_VERSION) + fixtures.push(current) + const reattachClient = new DaemonClient({ + socketPath: legacy.socketPath, + tokenPath: legacy.tokenPath, + protocolVersion: 22 + }) + await reattachClient.ensureConnected() + await expect( + reattachClient.request('createOrAttach', { + sessionId: 'legacy-live', + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ isNew: false }) + reattachClient.disconnect() + + const currentAdapter = new DaemonPtyAdapter({ + socketPath: current.socketPath, + tokenPath: current.tokenPath + }) + const secondCurrentClient = new DaemonClient({ + socketPath: current.socketPath, + tokenPath: current.tokenPath + }) + await secondCurrentClient.ensureConnected() + + // Why: failed adoption may overlap another authenticated app client, so + // only daemon-owned idle retirement can safely decide whether to exit. + await currentAdapter.disconnectOnly() + expect(current.child.exitCode).toBeNull() + await expect(secondCurrentClient.request('listSessions', undefined)).resolves.toEqual({ + sessions: [] + }) + secondCurrentClient.disconnect() + + await waitFor('v24 process exit', () => current.child.exitCode !== null) + expect(existsSync(current.tokenPath)).toBe(false) + expect(existsSync(current.pidPath)).toBe(false) + if (process.platform !== 'win32') { + expect(existsSync(current.socketPath)).toBe(false) + } + expect(legacy.child.exitCode).toBeNull() + } catch (error) { + testError = error + } + + const results = await Promise.allSettled(fixtures.map((fixture) => stopFixture(fixture))) + const cleanupErrors = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + try { + rmSync(rootDir, { recursive: true, force: true }) + } catch (error) { + cleanupErrors.push(error) + } + if (testError !== undefined && cleanupErrors.length > 0) { + throw new AggregateError([testError, ...cleanupErrors], 'Lifecycle test and cleanup failed') + } + if (testError !== undefined) { + throw testError + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, 'Lifecycle fixture cleanup failed') + } +}) diff --git a/tests/e2e/fixtures/daemon-lifecycle-entry.ts b/tests/e2e/fixtures/daemon-lifecycle-entry.ts new file mode 100644 index 000000000..0e09897b0 --- /dev/null +++ b/tests/e2e/fixtures/daemon-lifecycle-entry.ts @@ -0,0 +1,122 @@ +import process from 'node:process' +import { writeFileSync } from 'node:fs' +import { startDaemon, type DaemonHandle } from '../../../src/main/daemon/daemon-main' +import { serializeDaemonPidFile } from '../../../src/main/daemon/daemon-spawner' +import type { SubprocessHandle } from '../../../src/main/daemon/session' + +type FixtureArgs = { + protocolVersion: number + socketPath: string + tokenPath: string + pidPath?: string + launchNonce?: string +} + +function parseArgs(argv: string[]): FixtureArgs { + const values = new Map() + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (!key || !value) { + throw new Error('Lifecycle fixture arguments must be key/value pairs') + } + values.set(key, value) + } + const protocolVersion = Number(values.get('--protocol')) + const socketPath = values.get('--socket') + const tokenPath = values.get('--token') + const pidPath = values.get('--pid-record') + const launchNonce = values.get('--launch-nonce') + if ( + !Number.isInteger(protocolVersion) || + protocolVersion < 1 || + !socketPath || + !tokenPath || + Boolean(pidPath) !== Boolean(launchNonce) + ) { + throw new Error('Invalid lifecycle fixture arguments') + } + return { + protocolVersion, + socketPath, + tokenPath, + ...(pidPath && launchNonce ? { pidPath, launchNonce } : {}) + } +} + +function createFixtureSubprocess(): SubprocessHandle { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + let exited = false + const exit = (code: number): void => { + if (exited) { + return + } + exited = true + onExit?.(code) + } + return { + pid: process.pid, + getForegroundProcess: () => null, + write: (data) => onData?.(data), + resize: () => {}, + kill: () => exit(0), + forceKill: () => exit(137), + signal: () => {}, + onData: (callback) => { + onData = callback + }, + onExit: (callback) => { + onExit = callback + }, + dispose: () => exit(0) + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + const startedAtMs = Date.now() - process.uptime() * 1000 + let daemon: DaemonHandle | null = await startDaemon({ + protocolVersion: args.protocolVersion, + socketPath: args.socketPath, + tokenPath: args.tokenPath, + ...(args.pidPath ? { pidPath: args.pidPath } : {}), + ...(args.launchNonce ? { launchNonce: args.launchNonce } : {}), + startedAtMs, + spawnSubprocess: () => createFixtureSubprocess(), + onIdleShutdown: () => process.exit(0) + }) + if (args.pidPath && args.launchNonce) { + writeFileSync( + args.pidPath, + serializeDaemonPidFile({ + pid: process.pid, + startedAtMs, + launchNonce: args.launchNonce + }), + { mode: 0o600, flag: 'wx' } + ) + } + + let shuttingDown = false + const shutdown = async (): Promise => { + if (shuttingDown) { + return + } + shuttingDown = true + try { + await daemon?.shutdown() + daemon = null + } finally { + process.exit(0) + } + } + process.on('SIGTERM', () => void shutdown()) + process.on('SIGINT', () => void shutdown()) + process.send?.({ type: 'ready', pid: process.pid, startedAtMs }) +} + +void main().catch((error) => { + console.error(error) + process.exit(1) +})