From d3e8d711af916212fe03b047dfa3a161abefe969 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:31:39 -0700 Subject: [PATCH] refactor(daemon): split oversized PTY services --- config/max-lines-baseline.txt | 3 - src/main/daemon/daemon-client-connections.ts | 249 ++ .../daemon/daemon-client-rpc-request.test.ts | 2 +- src/main/daemon/daemon-endpoint-errors.ts | 40 + .../daemon/daemon-endpoint-incarnation.ts | 46 + src/main/daemon/daemon-endpoint-lifecycle.ts | 173 + .../daemon/daemon-endpoint-ownership.test.ts | 18 +- .../daemon/daemon-history-recovery-freeze.ts | 13 + src/main/daemon/daemon-idle-shutdown.test.ts | 104 +- src/main/daemon/daemon-init.ts | 3 +- src/main/daemon/daemon-listener-registry.ts | 19 + src/main/daemon/daemon-main.ts | 3 +- ...aemon-preflight-client-replacement.test.ts | 11 +- ...pty-adapter-protocol-compatibility.test.ts | 3 +- src/main/daemon/daemon-pty-adapter.ts | 2999 +---------------- .../daemon/daemon-pty-buffer-snapshots.ts | 202 ++ .../daemon-pty-checkpoint-persistence.ts | 192 ++ .../daemon/daemon-pty-checkpoint-scheduler.ts | 251 ++ .../daemon/daemon-pty-connection-lifecycle.ts | 156 + src/main/daemon/daemon-pty-daemon-recovery.ts | 290 ++ .../daemon/daemon-pty-event-subscriptions.ts | 157 + .../daemon/daemon-pty-lifecycle-errors.ts | 13 + .../daemon/daemon-pty-process-inspection.ts | 177 + .../daemon/daemon-pty-provider-sequence.ts | 13 + src/main/daemon/daemon-pty-runtime-state.ts | 260 ++ src/main/daemon/daemon-pty-session-control.ts | 357 ++ .../daemon/daemon-pty-session-inventory.ts | 174 + src/main/daemon/daemon-pty-session-spawn.ts | 281 ++ .../daemon/daemon-pty-spawn-preparations.ts | 118 + src/main/daemon/daemon-pty-spawn-request.ts | 202 ++ src/main/daemon/daemon-pty-spawn-result.ts | 326 ++ src/main/daemon/daemon-request-deadline.ts | 25 + src/main/daemon/daemon-request-router.ts | 282 ++ ...on-server-async-spawn-cancellation.test.ts | 33 +- ...daemon-server-attachment-lifecycle.test.ts | 26 +- .../daemon-server-error-handling.test.ts | 154 +- .../daemon-server-kill-attribution.test.ts | 16 +- src/main/daemon/daemon-server-lifecycle.ts | 278 ++ src/main/daemon/daemon-server-options.ts | 40 + src/main/daemon/daemon-server.test.ts | 58 +- src/main/daemon/daemon-server.ts | 1644 ++------- src/main/daemon/daemon-session-attachments.ts | 67 + .../daemon-session-background-routing.ts | 55 + ...n-final-checkpoint-caller-deadline.test.ts | 3 +- ...emon-stream-droppability-lifecycle.test.ts | 43 +- src/main/daemon/daemon-terminal-admission.ts | 213 ++ .../daemon/pty-subprocess-test-harness.ts | 5 +- src/main/daemon/pty-subprocess.ts | 1404 +------- .../foreground-process-tracker.ts | 275 ++ .../daemon/pty-subprocess/native-pty-spawn.ts | 82 + .../pty-subprocess/pre-listener-events.ts | 90 + .../pty-subprocess/shell-launch-plan.ts | 234 ++ .../pty-subprocess/spawn-environment.ts | 189 ++ .../daemon/pty-subprocess/spawn-preflight.ts | 221 ++ .../pty-subprocess/subprocess-handle.ts | 202 ++ .../daemon/terminal-host-session-create.ts | 2 + .../daemon/terminal-host-session-listing.ts | 2 +- .../__fixtures__/wsl-invocation-allowlist.txt | 1 - 58 files changed, 6483 insertions(+), 6016 deletions(-) create mode 100644 src/main/daemon/daemon-client-connections.ts create mode 100644 src/main/daemon/daemon-endpoint-errors.ts create mode 100644 src/main/daemon/daemon-endpoint-incarnation.ts create mode 100644 src/main/daemon/daemon-endpoint-lifecycle.ts create mode 100644 src/main/daemon/daemon-history-recovery-freeze.ts create mode 100644 src/main/daemon/daemon-listener-registry.ts create mode 100644 src/main/daemon/daemon-pty-buffer-snapshots.ts create mode 100644 src/main/daemon/daemon-pty-checkpoint-persistence.ts create mode 100644 src/main/daemon/daemon-pty-checkpoint-scheduler.ts create mode 100644 src/main/daemon/daemon-pty-connection-lifecycle.ts create mode 100644 src/main/daemon/daemon-pty-daemon-recovery.ts create mode 100644 src/main/daemon/daemon-pty-event-subscriptions.ts create mode 100644 src/main/daemon/daemon-pty-lifecycle-errors.ts create mode 100644 src/main/daemon/daemon-pty-process-inspection.ts create mode 100644 src/main/daemon/daemon-pty-provider-sequence.ts create mode 100644 src/main/daemon/daemon-pty-runtime-state.ts create mode 100644 src/main/daemon/daemon-pty-session-control.ts create mode 100644 src/main/daemon/daemon-pty-session-inventory.ts create mode 100644 src/main/daemon/daemon-pty-session-spawn.ts create mode 100644 src/main/daemon/daemon-pty-spawn-preparations.ts create mode 100644 src/main/daemon/daemon-pty-spawn-request.ts create mode 100644 src/main/daemon/daemon-pty-spawn-result.ts create mode 100644 src/main/daemon/daemon-request-deadline.ts create mode 100644 src/main/daemon/daemon-request-router.ts create mode 100644 src/main/daemon/daemon-server-lifecycle.ts create mode 100644 src/main/daemon/daemon-server-options.ts create mode 100644 src/main/daemon/daemon-session-attachments.ts create mode 100644 src/main/daemon/daemon-session-background-routing.ts create mode 100644 src/main/daemon/daemon-terminal-admission.ts create mode 100644 src/main/daemon/pty-subprocess/foreground-process-tracker.ts create mode 100644 src/main/daemon/pty-subprocess/native-pty-spawn.ts create mode 100644 src/main/daemon/pty-subprocess/pre-listener-events.ts create mode 100644 src/main/daemon/pty-subprocess/shell-launch-plan.ts create mode 100644 src/main/daemon/pty-subprocess/spawn-environment.ts create mode 100644 src/main/daemon/pty-subprocess/spawn-preflight.ts create mode 100644 src/main/daemon/pty-subprocess/subprocess-handle.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 85ea75132d2..6ee63bbea2a 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -22,9 +22,6 @@ inline src/main/codex-accounts/service.ts inline src/main/codex/config-toml-trust.ts inline src/main/codex/hook-service.ts inline src/main/daemon/daemon-init.ts -inline src/main/daemon/daemon-pty-adapter.ts -inline src/main/daemon/daemon-server.ts -inline src/main/daemon/pty-subprocess.ts inline src/main/git/repo.ts inline src/main/git/runner.ts inline src/main/git/status.ts diff --git a/src/main/daemon/daemon-client-connections.ts b/src/main/daemon/daemon-client-connections.ts new file mode 100644 index 00000000000..52f8b0db8cd --- /dev/null +++ b/src/main/daemon/daemon-client-connections.ts @@ -0,0 +1,249 @@ +import type { Socket } from 'node:net' +import { StringDecoder } from 'node:string_decoder' +import type { DaemonFileLog } from './daemon-file-log' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { createNdjsonParser, encodeNdjson } from './ndjson' +import type { DaemonRequest, HelloMessage } from './types' + +export type ConnectedDaemonClient = { + clientId: string + controlSocket: Socket + streamSocket: Socket | null + authenticatedPairEstablished: boolean +} + +type DaemonClientConnectionOptions = { + token: string + protocolVersion: number + identity: { + launchNonce: string | null + startedAtMs: number | null + entryPath: string | null + appVersion: string | null + spawnerExecPath: string | null + } + log: DaemonFileLog + streamDataBatcher: DaemonStreamDataBatcher + isAcceptingWork: () => boolean + onTransportChanged: () => void + onConnectionAccepted: () => void + onAuthenticatedPair: () => void + onLastAuthenticatedClientDisconnected: () => void + onControlRequest: (socket: Socket, clientId: string, request: DaemonRequest) => void + onControlReplaced: (clientId: string) => void + onClientDisconnected: (clientId: string) => void + onStreamDisconnected: (clientId: string) => void +} + +export class DaemonClientConnections { + private readonly clients = new Map() + private readonly transportSockets = new Set() + + constructor(private readonly options: DaemonClientConnectionOptions) {} + + accept(socket: Socket): void { + this.options.onConnectionAccepted() + this.transportSockets.add(socket) + socket.once('close', () => { + this.transportSockets.delete(socket) + this.options.onTransportChanged() + }) + socket.on('error', () => socket.destroy()) + + if (!this.options.isAcceptingWork()) { + socket.end( + encodeNdjson({ + type: 'hello', + ok: false, + error: 'Daemon temporarily unavailable; reconnect', + retryable: true + }) + ) + return + } + const decoder = new StringDecoder('utf8') + const parser = createNdjsonParser( + (message) => this.handleFirstMessage(socket, message), + () => socket.destroy() + ) + socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) + } + + get(clientId: string): ConnectedDaemonClient | undefined { + return this.clients.get(clientId) + } + + values(): IterableIterator { + return this.clients.values() + } + + get size(): number { + return this.clients.size + } + + get transportCount(): number { + return this.transportSockets.size + } + + hasOnlyTransportsFor(client: ConnectedDaemonClient): boolean { + return [...this.transportSockets].every( + (transport) => transport === client.controlSocket || transport === client.streamSocket + ) + } + + dispose(): void { + for (const client of this.clients.values()) { + client.controlSocket.destroy() + client.streamSocket?.destroy() + } + this.clients.clear() + for (const socket of this.transportSockets) { + socket.destroy() + } + this.transportSockets.clear() + } + + private handleFirstMessage(socket: Socket, message: unknown): void { + const hello = message as HelloMessage + if (hello.type !== 'hello') { + this.options.log.log('client-hello-rejected', { reason: 'expected-hello' }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) + socket.destroy() + return + } + if (hello.version !== this.options.protocolVersion) { + this.options.log.log('client-hello-rejected', { + reason: 'protocol-mismatch', + clientVersion: hello.version + }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) + socket.destroy() + return + } + if (hello.token !== this.options.token) { + this.options.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) + socket.destroy() + return + } + if (hello.role !== 'control' && hello.role !== 'stream') { + this.options.log.log('client-hello-rejected', { + reason: 'invalid-role', + role: hello.role + }) + socket.end(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid role' })) + return + } + + this.options.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) + const identity = this.options.identity + socket.write( + encodeNdjson({ + type: 'hello', + ok: true, + ...(identity.launchNonce && identity.startedAtMs + ? { + daemonIdentity: { + pid: process.pid, + startedAtMs: identity.startedAtMs, + launchNonce: identity.launchNonce, + ...(identity.entryPath ? { entryPath: identity.entryPath } : {}), + ...(identity.appVersion ? { appVersion: identity.appVersion } : {}), + ...(identity.spawnerExecPath ? { spawnerExecPath: identity.spawnerExecPath } : {}) + } + } + : {}) + }) + ) + + if (hello.role === 'control') { + this.installControlSocket(socket, hello.clientId) + return + } + if (hello.role === 'stream') { + const client = this.clients.get(hello.clientId) + if (!client) { + socket.destroy() + return + } + this.installStreamSocket(socket, client) + client.authenticatedPairEstablished = true + this.options.onAuthenticatedPair() + return + } + // Parsed wire data is not made safe by the HelloMessage assertion above. + socket.destroy() + } + + private installControlSocket(socket: Socket, clientId: string): void { + const previous = this.clients.get(clientId) + const client: ConnectedDaemonClient = { + clientId, + controlSocket: socket, + streamSocket: null, + authenticatedPairEstablished: false + } + this.clients.set(clientId, client) + this.setupControlParser(socket, clientId) + if (!previous) { + return + } + this.options.onControlReplaced(clientId) + this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) + previous.streamSocket?.destroy() + previous.controlSocket.destroy() + } + + private setupControlParser(socket: Socket, clientId: string): void { + const decoder = new StringDecoder('utf8') + const parser = createNdjsonParser( + (message) => this.options.onControlRequest(socket, clientId, message as DaemonRequest), + () => {} + ) + socket.removeAllListeners('data') + socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) + socket.on('close', () => { + const client = this.clients.get(clientId) + if (client?.controlSocket !== socket) { + return + } + this.options.onClientDisconnected(clientId) + const wasFullyAuthenticated = client.authenticatedPairEstablished + client.streamSocket?.destroy() + this.clients.delete(clientId) + this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated) + this.options.onTransportChanged() + }) + } + + private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void { + if ( + wasFullyAuthenticated && + ![...this.clients.values()].some((client) => client.authenticatedPairEstablished) && + this.options.isAcceptingWork() + ) { + this.options.onLastAuthenticatedClientDisconnected() + } + } + + private installStreamSocket(socket: Socket, client: ConnectedDaemonClient): void { + const previous = client.streamSocket + socket.removeAllListeners('data') + client.streamSocket = socket + socket.on('drain', () => this.options.streamDataBatcher.flush(client.clientId)) + const cleanup = (): void => { + socket.removeListener('close', cleanup) + socket.removeListener('error', cleanup) + if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) { + return + } + this.options.onStreamDisconnected(client.clientId) + client.streamSocket = null + } + socket.on('close', cleanup) + socket.on('error', cleanup) + if (previous && previous !== socket) { + previous.destroy() + } + } +} diff --git a/src/main/daemon/daemon-client-rpc-request.test.ts b/src/main/daemon/daemon-client-rpc-request.test.ts index 87935bb589e..b495ee07425 100644 --- a/src/main/daemon/daemon-client-rpc-request.test.ts +++ b/src/main/daemon/daemon-client-rpc-request.test.ts @@ -2,7 +2,7 @@ import type { Socket } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' import { DaemonPendingRequests } from './daemon-client-pending-requests' import { requestDaemonRpc } from './daemon-client-rpc-request' -import { isDaemonGoneError } from './daemon-pty-adapter' +import { isDaemonGoneError } from './daemon-endpoint-errors' import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE, DaemonConnectionLostError, diff --git a/src/main/daemon/daemon-endpoint-errors.ts b/src/main/daemon/daemon-endpoint-errors.ts new file mode 100644 index 00000000000..663b11ff3fa --- /dev/null +++ b/src/main/daemon/daemon-endpoint-errors.ts @@ -0,0 +1,40 @@ +import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE } from './types' + +/** + * Narrow on purpose: only the daemon's own reply for a request type it does not implement. + * A transient failure must stay unproven rather than be mistaken for a missing capability. + * The server throws `Unknown request type: `; the client rejects with that text, which + * `addNodePtyRecoveryHint` only ever prepends to. + */ +export function isUnknownRequestTypeError(err: unknown): boolean { + return err instanceof Error && err.message.includes('Unknown request type') +} + +// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); +// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. +export function isDaemonGoneError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') { + return true + } + const msg = err.message + return ( + msg === 'Connection lost' || + msg === 'Not connected' || + msg === 'Hello response timed out' || + msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE || + msg === DAEMON_ENDPOINT_LOST_MESSAGE + ) +} + +export function isMissingWindowsNamedPipeError(err: unknown): boolean { + if (process.platform !== 'win32' || !(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + return errno.code === 'ENOENT' && errno.syscall === 'connect' +} diff --git a/src/main/daemon/daemon-endpoint-incarnation.ts b/src/main/daemon/daemon-endpoint-incarnation.ts new file mode 100644 index 00000000000..4a7dc6ea4b4 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-incarnation.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' + +export function sameEndpointIdentity( + left: DaemonEndpointIdentity, + right: DaemonEndpointIdentity +): boolean { + return ( + left.pid === right.pid && + left.startedAtMs === right.startedAtMs && + left.launchNonce === right.launchNonce + ) +} + +export function exactDaemonIncarnationForPidRecord( + identity: DaemonEndpointIdentity, + pidRecord: ParsedDaemonPid | null +): ExactDaemonIncarnation { + return { + identity: { ...identity }, + ...(process.platform === 'linux' && + pidRecord?.pid === identity.pid && + pidRecord.startedAtMs === identity.startedAtMs && + pidRecord.launchNonce === identity.launchNonce && + pidRecord.linuxStartTicks && + pidRecord.bootId + ? { + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : {}) + } +} + +export function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { + if (!pidPath) { + return null + } + try { + return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) + } catch { + return null + } +} diff --git a/src/main/daemon/daemon-endpoint-lifecycle.ts b/src/main/daemon/daemon-endpoint-lifecycle.ts new file mode 100644 index 00000000000..035d56af574 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-lifecycle.ts @@ -0,0 +1,173 @@ +import { chmodSync, unlinkSync, writeFileSync } from 'node:fs' +import type { DaemonFileLog } from './daemon-file-log' +import { + DaemonEndpointUnavailableError, + getDaemonSocketBindPath, + publishDaemonEndpoint, + readDaemonEndpointOwnershipState, + type DaemonEndpointOwnershipState, + type DaemonSocketIdentity +} from './daemon-endpoint-ownership' +import { probeSocketConnect } from './daemon-endpoint-probe' +import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' + +type DaemonEndpointLifecycleOptions = { + socketPath: string + tokenPath: string + pidPath: string | null + launchNonce: string | null + token: string + publishEndpointOwnership: () => void + log: DaemonFileLog + isServing: () => boolean + onOwnershipLost: () => void +} + +export class DaemonEndpointLifecycle { + private static readonly OWNERSHIP_POLL_MS = 30 * 1000 + private static readonly LOSS_CONFIRMATIONS = 2 + private ownedSocketIdentity: DaemonSocketIdentity | null = null + private ownershipTimer: ReturnType | null = null + private ownershipLossStreak = 0 + private ownershipLost = false + + constructor(private readonly options: DaemonEndpointLifecycleOptions) {} + + bindPath(): string { + return process.platform === 'win32' + ? this.options.socketPath + : getDaemonSocketBindPath(this.options.socketPath) + } + + secureBindPath(bindPath: string): void { + try { + chmodSync(bindPath, 0o600) + } catch { + // Best-effort on platforms that support it. + } + } + + abandonBindPath(bindPath: string): void { + if (process.platform === 'win32') { + return + } + try { + unlinkSync(bindPath) + } catch { + // Already consumed by publication, or never created. + } + } + + async publish(bindPath: string): Promise { + const outcome = await publishDaemonEndpoint( + bindPath, + this.options.socketPath, + probeSocketConnect + ) + if (outcome.status !== 'published') { + this.options.log.log('endpoint-publish-declined', { reason: outcome.status }) + console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`) + throw new DaemonEndpointUnavailableError(outcome.status) + } + this.ownedSocketIdentity = outcome.identity + let publishedOwnership = false + try { + this.options.publishEndpointOwnership() + publishedOwnership = true + writeFileSync(this.options.tokenPath, this.options.token, { mode: 0o600 }) + } catch (error) { + if (publishedOwnership && this.options.pidPath && this.options.launchNonce) { + unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce) + } + this.ownedSocketIdentity = null + throw error + } + } + + retireUnstarted(): void { + this.stopOwnershipWatch() + this.unlinkOwnedArtifacts() + } + + unlinkOwnedArtifacts(): void { + unlinkOwnedDaemonTokenFile(this.options.tokenPath, this.options.token) + if (this.options.pidPath && this.options.launchNonce) { + unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce) + } + // The canonical endpoint stays: removing it could delete a replacement's name. + this.ownedSocketIdentity = null + } + + stopOwnershipWatch(): void { + if (this.ownershipTimer === null) { + return + } + clearInterval(this.ownershipTimer) + this.ownershipTimer = null + } + + hasLostOwnership(): boolean { + if (this.ownershipLost) { + return true + } + return this.observeOwnership() === 'lost' + } + + get lost(): boolean { + return this.ownershipLost + } + + requestRetirementForLoss(): void { + const alreadyLost = this.ownershipLost + this.ownershipLost = true + this.ownedSocketIdentity = null + if (!alreadyLost) { + this.options.log.log('endpoint-ownership-lost', { + socketPath: this.options.socketPath + }) + console.warn( + '[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end' + ) + } + this.stopOwnershipWatch() + this.options.onOwnershipLost() + } + + startOwnershipWatch(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity) { + return + } + this.ownershipTimer = setInterval( + () => this.checkOwnership(), + DaemonEndpointLifecycle.OWNERSHIP_POLL_MS + ) + this.ownershipTimer.unref() + } + + private checkOwnership(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity) { + return + } + if (this.observeOwnership() !== 'lost') { + return + } + this.ownershipLossStreak++ + if (this.ownershipLossStreak >= DaemonEndpointLifecycle.LOSS_CONFIRMATIONS) { + this.requestRetirementForLoss() + } + } + + private observeOwnership(): DaemonEndpointOwnershipState { + if (process.platform === 'win32' || !this.ownedSocketIdentity || !this.options.isServing()) { + return 'indeterminate' + } + const state = readDaemonEndpointOwnershipState( + this.options.socketPath, + this.ownedSocketIdentity + ) + if (state !== 'lost') { + this.ownershipLossStreak = 0 + } + return state + } +} diff --git a/src/main/daemon/daemon-endpoint-ownership.test.ts b/src/main/daemon/daemon-endpoint-ownership.test.ts index adb8eca4ae9..cf6af2e0956 100644 --- a/src/main/daemon/daemon-endpoint-ownership.test.ts +++ b/src/main/daemon/daemon-endpoint-ownership.test.ts @@ -205,13 +205,13 @@ describe('daemon endpoint ownership publication', () => { await server.start() const daemon = server as unknown as { - checkEndpointOwnership: () => void - retirementRequested: boolean + endpoint: { checkOwnership: () => void } + lifecycle: { retirementRequested: boolean } } // An inconclusive or matching probe must never retire a healthy daemon, however often it runs. - daemon.checkEndpointOwnership() - daemon.checkEndpointOwnership() - expect(daemon.retirementRequested).toBe(false) + daemon.endpoint.checkOwnership() + daemon.endpoint.checkOwnership() + expect(daemon.lifecycle.retirementRequested).toBe(false) // Another daemon takes the endpoint name. unlinkSync(socketPath) @@ -224,13 +224,13 @@ describe('daemon endpoint ownership publication', () => { try { // A single observation can land inside a replacement's unlink-then-link gap, so the // first one must not retire anything. - daemon.checkEndpointOwnership() - expect(daemon.retirementRequested).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.lifecycle.retirementRequested).toBe(false) - daemon.checkEndpointOwnership() + daemon.endpoint.checkOwnership() // Why: retirement drains rather than kills — an orphaned daemon stops being a // permanent unreachable host without tearing live sessions out from under the user. - expect(daemon.retirementRequested).toBe(true) + expect(daemon.lifecycle.retirementRequested).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) try { diff --git a/src/main/daemon/daemon-history-recovery-freeze.ts b/src/main/daemon/daemon-history-recovery-freeze.ts new file mode 100644 index 00000000000..c9c3f66b2b3 --- /dev/null +++ b/src/main/daemon/daemon-history-recovery-freeze.ts @@ -0,0 +1,13 @@ +import type { HistoryRecoveryContext } from './daemon-pty-runtime-state' +import type { HistoryRecoveryFreeze } from './history-manager' + +// Taking and clearing together prevents any recovery branch from retaining a consumed freeze. +export function takeHistoryRecoveryFreeze( + historyRecovery: HistoryRecoveryContext, + sessionId: string +): HistoryRecoveryFreeze | undefined { + const freeze = + historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined + historyRecovery.freeze = null + return freeze +} diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts index 7dce6f7f763..05f4b3aa39f 100644 --- a/src/main/daemon/daemon-idle-shutdown.test.ts +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -58,6 +58,22 @@ class ManualIdleClock { return this.timers.size } } +type DaemonServerInternals = { + connections: { + clients: Map + accept(socket: Socket): void + } + admission: { createOrAttachInFlight: number } + lifecycle: { + retirementRequested: boolean + state: string + beginIdleShutdown(): void + } + requestRouter: { + route(clientId: string, request: unknown): Promise + } + host: { dispose: () => Promise } +} function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } { let onExit: ((code: number) => void) | null = null @@ -222,11 +238,8 @@ describe('current daemon lifecycle retirement', () => { 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 daemon = server as unknown as DaemonServerInternals + const controlSocket = [...daemon.connections.clients.values()][0].controlSocket const originalWrite = controlSocket.write.bind(controlSocket) let replyFlushed: (() => void) | undefined vi.spyOn(controlSocket, 'write').mockImplementation((( @@ -252,11 +265,8 @@ describe('current daemon lifecycle retirement', () => { 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 daemon = server as unknown as DaemonServerInternals + const controlSocket = [...daemon.connections.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']) @@ -301,8 +311,8 @@ describe('current daemon lifecycle retirement', () => { await startServer() const client = new DaemonClient({ socketPath, tokenPath }) await client.ensureConnected() - const daemon = server as unknown as { createOrAttachInFlight: number } - daemon.createOrAttachInFlight = 1 + const daemon = server as unknown as DaemonServerInternals + daemon.admission.createOrAttachInFlight = 1 await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: false @@ -395,13 +405,13 @@ describe('current daemon lifecycle retirement', () => { 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) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(onIdleShutdown).not.toHaveBeenCalled() const second = new DaemonClient({ socketPath, tokenPath }) await second.ensureConnected() - await waitFor(() => !daemon.retirementRequested) + await waitFor(() => !daemon.lifecycle.retirementRequested) subprocess.exit(0) expect(onIdleShutdown).not.toHaveBeenCalled() @@ -444,8 +454,8 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { clients: Map } - await waitFor(() => daemon.clients.has('startup-health-control')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.clients.has('startup-health-control')) clock.advanceBy(1_000) healthControl.destroy() @@ -465,18 +475,15 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - clients: Map - retirementRequested: boolean - } - await waitFor(() => daemon.clients.has('startup-control-create')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.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(daemon.lifecycle.retirementRequested).toBe(false) expect(clock.pendingCount).toBe(0) clock.advanceBy(1_000) @@ -501,14 +508,11 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - clients: Map - retirementRequested: boolean - } - await waitFor(() => daemon.clients.has('control-only-overlap')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.clients.has('control-only-overlap')) paired.disconnect() - await waitFor(() => daemon.retirementRequested) + await waitFor(() => daemon.lifecycle.retirementRequested) expect(clock.pendingCount).toBe(0) const response = await requestOnRawSocket(incomplete, { id: 'overlap-control-create', @@ -516,7 +520,7 @@ describe('current daemon lifecycle retirement', () => { payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } }) expect(response.error).toContain('connection is incomplete') - expect(daemon.retirementRequested).toBe(true) + expect(daemon.lifecycle.retirementRequested).toBe(true) incomplete.destroy() await waitFor(() => onIdleShutdown.mock.calls.length === 1) @@ -538,10 +542,8 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - retirementRequested: boolean - } - await waitFor(() => daemon.retirementRequested) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(clock.pendingCount).toBe(0) replacementControl.destroy() @@ -554,8 +556,8 @@ describe('current daemon lifecycle retirement', () => { 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) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(onIdleShutdown).not.toHaveBeenCalled() expect(clock.pendingCount).toBe(0) @@ -612,14 +614,11 @@ describe('current daemon lifecycle retirement', () => { 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' + const daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.state = 'idle-shutdown-pending' await expect( - daemon.routeRequest('late-client', { + daemon.requestRouter.route('late-client', { id: 'late-create', type: 'createOrAttach', payload: { sessionId: 'late', cols: 80, rows: 24 } @@ -629,30 +628,23 @@ describe('current daemon lifecycle retirement', () => { 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 + const daemon = server as unknown as DaemonServerInternals + daemon.admission.createOrAttachInFlight = 1 - daemon.beginIdleShutdown() + daemon.lifecycle.beginIdleShutdown() - expect(daemon.idleShutdownState).toBe('running') + expect(daemon.lifecycle.state).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 daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.state = 'idle-shutdown-pending' const socket = new EventEmitter() as Socket socket.end = vi.fn() as unknown as Socket['end'] - daemon.handleConnection(socket) + daemon.connections.accept(socket) const payload = vi.mocked(socket.end).mock.calls[0]?.[0] expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index e79d4b9dfc8..d6c4240b19b 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -18,7 +18,8 @@ import { type DaemonProcessHandle } from './daemon-spawner' import { DAEMON_EXIT_ENDPOINT_OCCUPIED } from './daemon-endpoint-ownership' -import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { DaemonRespawnReason } from './daemon-pty-runtime-state' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' import { diff --git a/src/main/daemon/daemon-listener-registry.ts b/src/main/daemon/daemon-listener-registry.ts new file mode 100644 index 00000000000..2fef9e7eef6 --- /dev/null +++ b/src/main/daemon/daemon-listener-registry.ts @@ -0,0 +1,19 @@ +export function removeDaemonListener(listeners: T[], listener: T): void { + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } +} + +export function notifyDaemonAuditListeners( + listeners: readonly ((value: T) => void)[], + value: T +): void { + for (const listener of listeners.slice()) { + try { + listener(value) + } catch { + // Audit observers cannot affect daemon operations. + } + } +} diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 63e763474bc..b2683b0af99 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -1,4 +1,5 @@ -import { DaemonServer, type DaemonServerOptions } from './daemon-server' +import { DaemonServer } from './daemon-server' +import type { DaemonServerOptions } from './daemon-server-options' import type { DaemonFileLog } from './daemon-file-log' export type DaemonStartOptions = { diff --git a/src/main/daemon/daemon-preflight-client-replacement.test.ts b/src/main/daemon/daemon-preflight-client-replacement.test.ts index e6204708c25..7fbb46ebfd5 100644 --- a/src/main/daemon/daemon-preflight-client-replacement.test.ts +++ b/src/main/daemon/daemon-preflight-client-replacement.test.ts @@ -10,8 +10,8 @@ import { PROTOCOL_VERSION } from './types' import type { SubprocessHandle } from './session-subprocess-handle' type DaemonServerPrivate = { - pendingPtySpawnPreparations: Map> - clients: Map + preparations: { pending: Map> } + connections: { clients: Map } } function createMockSubprocess(): SubprocessHandle { @@ -96,7 +96,7 @@ describe('daemon preflight client replacement', () => { await replacement.ensureConnected() finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).not.toHaveBeenCalled() @@ -139,12 +139,13 @@ describe('daemon preflight client replacement', () => { disconnected.stream.destroy() await vi.waitFor(() => expect( - (server as unknown as DaemonServerPrivate).clients.get(disconnectedClientId)?.streamSocket + (server as unknown as DaemonServerPrivate).connections.clients.get(disconnectedClientId) + ?.streamSocket ).toBeNull() ) finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).toHaveBeenCalledOnce() diff --git a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts index 0ee95478743..aa47fac186e 100644 --- a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts +++ b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { rmSync } from 'node:fs' import { DaemonClient } from './client' -import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-control' import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 56bad83e39e..42bf0a8c267 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,2886 +1,9 @@ -/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */ -import { basename } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { DaemonClient } from './client' -import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' -import { getMacDaemonSystemResolverHealth } from './daemon-health' -import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' -import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' -import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' -import { - HistoryManager, - type HistoryCheckpointResult, - type HistoryRecoveryFreeze -} from './history-manager' -import { HistoryReader, type ColdRestoreInfo } from './history-reader' -import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' -import { mintPtySessionId, parsePtySessionId } from './pty-session-id' -import { supportsPtyStartupBarrier } from './shell-ready' -import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier' -import { - CLEAN_DISCONNECT_PROTOCOL_VERSION, - COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, - GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, - AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - DAEMON_UNAVAILABLE_RECONNECT_MESSAGE, - GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, - PROTOCOL_VERSION, - supportsMode2031UnsubscribeFact, - supportsPtyStartupIngress, - type CreateOrAttachResult, - type DaemonEvent, - type GetSnapshotResult, - type ListSessionsResult, - SessionNotFoundError, - type SessionInfo, - type TakePendingOutputResult -} from './types' -import { - GET_SIZE_PROTOCOL_VERSION, - HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, - SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION -} from './daemon-protocol-version' -import { - isAgentSessionClaimedSpawnResult, - isAgentSessionOwnerBinding, - type AgentSessionOwnerBinding -} from '../../shared/agent-session-host-authority' -import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' -import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot' -import type { - IPtyProvider, - PtyBackgroundStreamEvent, - PtyProviderBufferSnapshot, - PtyProcessInfo, - PtySpawnOptions, - PtySpawnResult -} from '../providers/types' -import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' -import { isShellProcess } from '../../shared/agent-detection' -import { resolveWslSessionContext } from './wsl-session-context' -import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' -import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { resolveUnixShellPath } from '../providers/local-pty-utils' -import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { addWslEnvKeys } from '../wsl-env' -import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' -import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache' -import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' -import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' -import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' -import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window' -import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' -import { - iterateTerminalHistorySeedChunks, - measureTerminalHistorySeed, - TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS -} from './terminal-history-seed-chunks' -import { NdjsonLineTooLongError } from './ndjson' -import type { DaemonEndpointIdentity } from './daemon-hello-protocol' -import { - classifyDaemonAuditFailure, - recordAuthenticatedInventory, - type DaemonAuditContext, - type DaemonAuditObservation, - type DaemonAuditTrigger -} from './daemon-audit-classifier' -import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' -import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' -import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy' -import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import { DaemonPtyDaemonRecovery } from './daemon-pty-daemon-recovery' +import { supportsMode2031UnsubscribeFact, type DaemonEvent } from './types' +import type { IPtyProvider } from '../providers/types' -type PendingDaemonSpawnOperation = { - exitsBySessionId: Map - ignoredExitIncarnationIds: Set - ignoreNextExit: boolean -} - -type HistoryRecoveryContext = { - freeze: HistoryRecoveryFreeze | null - unreadableSessionId: string | null - identityChanged: boolean -} - -type SnapshotCheckpointResult = { - checkpoint: HistoryCheckpointResult - snapshot: NonNullable | null -} - -// Why take-and-clear together: every consuming branch must reset the field, so pairing them stops one from forgetting. -function takeRecoveryFreeze( - historyRecovery: HistoryRecoveryContext, - sessionId: string -): HistoryRecoveryFreeze | undefined { - const freeze = - historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined - historyRecovery.freeze = null - return freeze -} -function providerSequenceFromCreateOrAttach( - result: CreateOrAttachResult -): PtySpawnResult['providerSequence'] { - if (result.isNew) { - return { value: 0, generation: 'reset' } - } - return typeof result.snapshot?.outputSequence === 'number' - ? { value: result.snapshot.outputSequence, generation: 'continued' } - : undefined -} - -export type DaemonPtyAdapterOptions = { - socketPath: string - tokenPath: string - pidPath?: string - profileScope?: string - protocolVersion?: number - /** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */ - historyPath?: string - /** Runtime profile directory used to verify daemon TCC attribution. */ - runtimeDir?: string - /** Current packaged version, or null for unpackaged builds. */ - packagedAppVersion?: string | null - /** Forks a fresh daemon after endpoint death or a confirmed health replacement. */ - respawn?: (reason: DaemonRespawnReason) => Promise void)> -} - -export type DaemonRespawnReason = - | 'daemon_died' - | 'unhealthy_resolver' - | 'stale_bundle' - | 'severed_tcc_attribution' - -export type DaemonIdentityChangeEvent = { - previous: DaemonEndpointIdentity - current: DaemonEndpointIdentity -} - -const MAX_TOMBSTONES = 1000 -const MAX_CONCURRENT_CHECKPOINTS = 4 - -// Why a reattach deadline at all: a warm reattach is a user click, so it must never wait on a -// stalled history filesystem. Why 5s: a healthy deep-history rebuild replays at most -// DAEMON_RESTORE_SCROLLBACK_ROWS through the headless emulator in well under a second, so this -// only fires when the write path is genuinely wedged (STA-4173). -const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000 - -// Why far below the client's 30s default: a wedged daemon holds its socket open, so an unbounded -// probe stalls a pane mount for the full request timeout — and the owner fan-out waits on every -// adapter, so one hung daemon stalls each restoring pane. Answering "unknown" quickly is strictly -// better here: unknown never authorizes retirement, it only defers it. -export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 - -// Why: providers take an absolute teardown deadline, but the client RPC takes a -// relative timeout — convert only here, at the request itself, so sequential RPCs -// naturally share the remaining budget (undefined keeps the client's 30s default). -function remainingRequestTimeoutMs(deadlineMs: number | undefined): number | undefined { - return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now()) -} - -// Why a distinct error: teardown callers must read this as "not proven stopped" and leave the PTY -// alive, not as a kill that failed. It never matches isPtyAlreadyGoneError, so `stopAndWait` reports -// the pty unverified and worktree sleep declines to commit it. -export class FinalCheckpointWaitExpiredError extends Error { - constructor(sessionId: string) { - super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`) - this.name = 'FinalCheckpointWaitExpiredError' - } -} - -// Why only the caller's wait is bounded and never the work itself: cancelling durable work would -// silently drop what the user left on screen. This decides how long a caller blocks, nothing more — -// `work` keeps running behind an abandoned wait and still commits. False means the caller gave up. -// A rejection before the deadline still propagates, so a genuinely failed operation is not masked. -async function awaitWithinCallerDeadline( - work: Promise, - deadlineMs: number -): Promise { - // Why up front: once the race is abandoned nothing observes a later rejection here. - void work.catch(() => {}) - let timer: ReturnType | undefined - try { - return await Promise.race([ - work.then(() => true), - new Promise((resolve) => { - timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now())) - timer.unref?.() - }) - ]) - } finally { - clearTimeout(timer) - } -} - -export class TerminalKilledError extends Error { - constructor(sessionId: string) { - super(`Session "${sessionId}" was explicitly killed`) - this.name = 'TerminalKilledError' - } -} - -export class DaemonPtyAdapter implements IPtyProvider { - readonly protocolVersion: number - private socketPath: string - private tokenPath: string - private pidPath: string | null - private pidRecord: ParsedDaemonPid | null - private client: DaemonClient - private auditContext: DaemonAuditContext - private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null - private exactDaemonIncarnation: ExactDaemonIncarnation | null = null - private lastAuditObservation: DaemonAuditObservation | null = null - // Why: every listProcesses call republishes the same observation; unthrottled it drains the shared per-session telemetry ceiling. - private readonly trackAuditEligibility = createDaemonAuditEligibilityTracker() - private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = [] - private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = [] - private historyManager: HistoryManager | null - private historyReader: HistoryReader | null - private respawnFn: DaemonPtyAdapterOptions['respawn'] | null - private runtimeDir: string | null - private packagedAppVersion: string | null - private pendingRespawnAdoptionRelease: (() => void) | null = null - private respawnAdoptionClosed = false - // Why: concurrent spawn() calls hitting a dead daemon would each fork their own; this promise coalesces respawns so only the first forks and the rest await it. - private respawnPromise: Promise | null = null - private staleBundleReplacementPromise: Promise | null = null - private writeRecoveryPromise: Promise | null = null - private writeRecoveryAttempted = false - private dataListeners: ((payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void)[] = [] - private exitListeners: ((payload: { - id: string - code: number - incarnationId?: PtyIncarnationId - cause?: TerminalExitCause - }) => void)[] = [] - private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] - // Why: lets main fan a dead-endpoint signal to every affected pane, not just the written one (STA-2373 sibling-freeze). - private writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] - private removeEventListener: (() => void) | null = null - private initialCwds = new Map() - private wslDistrosBySessionId = new Map() - // Why: StrictMode/re-render remounts can call createOrAttach for a just-killed session; tombstones stop the daemon resurrecting it (Map evicts oldest-first, per terminal-host.ts). - private killedSessionTombstones = new Map() - // Why: React StrictMode double-mounts; this sticky cache returns the same cold restore data on remount until the renderer acknowledges it. - private sleepRestoreSessionIds = new Set() - private coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => { - this.sleepRestoreSessionIds.delete(sessionId) - }) - private activeSessionIds = new Set() - // Set only once this daemon has rejected `getSize` as unknown; its protocol number cannot prove it. - private getSizeUnsupported = false - // A replacement daemon has none of the old PTYs; only createOrAttach can make their bindings writable again. - private sessionsAwaitingDaemonRecovery = new Set() - private sessionIncarnations = new Map() - private pendingSpawnOperationsBySessionId = new Map>() - private pendingClaimSpawnOperations = new Set() - private historySpawnLocks = new Map>() - private dirtySessionVersions = new Map() - // Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot. - private sessionsNeedingFullCheckpoint = new Set() - // Why: overflow or an unpersisted drain breaks disk-to-queue continuity, so only the daemon emulator can safely re-anchor the next checkpoint. - private sessionsNeedingLiveCheckpoint = new Set() - // Why: a fresh adapter may reuse deep disk history only when the checkpoint proves it ends at the daemon's preceding pending-output batch. - private sessionsNeedingContinuityCheckpoint = new Set() - private checkpointTimer: ReturnType | null = null - private checkpointInFlight: Promise | null = null - // Why per session: exclusivity protects one session directory's tmp-write/rename, so ordering - // every session behind one tail let a single stalled checkpoint block all reattaches (STA-4173). - private checkpointQueue = new CheckpointSessionQueue() - private nonFinalCheckpointAdmissionSessionIds = new Set() - private nonFinalAdmissionDeniedSessionIds = new Set() - private nonFinalGlobalAdmissionWarningActive = false - private overlayDeadlineWarnedSessionIds = new Set() - private periodicDeadlineWarnedSessionIds = new Set() - private keepHistoryShutdowns = new Set>() - private disconnectOnlyPromise: Promise | null = null - // Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s. - private supportsCheckpoints: boolean - // Why: incremental checkpoints need the takePendingOutput RPC (v13+); older daemons fall back to full-snapshot checkpoints. - private supportsIncrementalCheckpoints: boolean - // Why: producer pause/resume notifications require v19+; gate them to silent no-ops on legacy daemons. - private supportsProducerFlowControl: boolean - private supportsAuthoritativeBufferSnapshots: boolean - private supportsStartupIngress: boolean - private pausedProducerSessionIds = new Set() - // Why tracked here: the daemon's background set dies with the daemon process/socket; re-sync on a fresh connection so hidden panes stay thinned. - private backgroundedSessionIds = new Set() - // Why: a daemon surviving a socket drop can hold a pause whose resume died with the connection; owe a resume on reconnect (daemon's 5s failsafe covers the gap). - private producerResumesOwedOnReconnect = new Set() - private static CHECKPOINT_INTERVAL_MS = 5_000 - // Why the background pass gets a longer deadline: deferral keeps the session dirty, so this - // should sit above slow-but-working disk rather than churn it. - private static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000 - // Why: streaming sessions re-trigger full multi-MB checkpoints every tick; this cooldown caps cap/overflow snapshots per session (~9x less writes, bounded cold-crash staleness). - private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000 - private lastFullCheckpointAt = new Map() - - supportsGitCredentialGuardHost(): boolean { - return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION - } - - canProvideAuthoritativeBufferSnapshot(_id: string): boolean { - return this.supportsAuthoritativeBufferSnapshots - } - - // Why one predicate (#9993): the attach-time clear and setPtyBackgrounded must agree on - // which daemons may hold a background hint. Daemons outlive the desktop that set it, so - // if these two drift a preserved daemon keeps a hint this process would never grant. - private get canDelegateBackgroundToDaemon(): boolean { - return ( - this.supportsAuthoritativeBufferSnapshots && - supportsMode2031UnsubscribeFact(this.protocolVersion) - ) - } - - constructor(opts: DaemonPtyAdapterOptions) { - this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.socketPath = opts.socketPath - this.tokenPath = opts.tokenPath - this.pidPath = opts.pidPath ?? null - this.pidRecord = readDaemonPidRecord(this.pidPath) - this.auditContext = { - protocolGeneration: this.protocolVersion, - provider: 'local-daemon', - endpoint: opts.socketPath, - tokenPath: opts.tokenPath, - endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', - profileScope: opts.profileScope ?? '' - } - this.client = new DaemonClient({ - socketPath: opts.socketPath, - tokenPath: opts.tokenPath, - protocolVersion: opts.protocolVersion - }) - this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null - this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null - this.respawnFn = opts.respawn ?? null - this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null - this.packagedAppVersion = opts.packagedAppVersion ?? null - this.supportsCheckpoints = this.protocolVersion >= 4 - this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 - this.supportsProducerFlowControl = this.protocolVersion >= 19 - this.supportsAuthoritativeBufferSnapshots = - this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION - this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) - this.client.onDisconnected(() => { - if (!this.respawnAdoptionClosed) { - // Why re-arm here: the latch is otherwise only cleared when every awaiting - // session rebinds, and background sessions (no mounted pane, so nothing ever - // calls createOrAttach for them) never do — which would leave the fan-out - // permanently latched off after the first death. Fires once per connection. - this.writeRecoveryAttempted = false - for (const id of this.activeSessionIds) { - this.sessionsAwaitingDaemonRecovery.add(id) - } - } - for (const id of this.pausedProducerSessionIds) { - this.producerResumesOwedOnReconnect.add(id) - } - this.pausedProducerSessionIds.clear() - this.observeAuditFailure('transport_closed') - }) - } - - getHistoryManager(): HistoryManager | null { - return this.historyManager - } - - getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { - return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null - } - - getLastAuditObservation(): DaemonAuditObservation | null { - return this.lastAuditObservation - } - - onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void { - this.identityChangeListeners.push(listener) - return () => removeListener(this.identityChangeListeners, listener) - } - - onAuditEligibilityObservation( - listener: (observation: DaemonAuditObservation) => void - ): () => void { - this.auditObservationListeners.push(listener) - return () => removeListener(this.auditObservationListeners, listener) - } - - supportsAgentSessionClaims(): boolean { - return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION - } - - providesAgentSessionOwnerListings(_ptyId: string): boolean { - return this.supportsAgentSessionClaims() - } - - supportsAgentSessionCreateOperations(): boolean { - // Why: old daemons never advertised the lower-owner protocol, so preserve their legacy launch. - return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION - } - - async spawn(opts: PtySpawnOptions): Promise { - const spawnOpts = this.withHistoryIsolation(opts) - const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) - const operation = { - exitsBySessionId: new Map(), - ignoredExitIncarnationIds: new Set(), - ignoreNextExit: false - } - const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set() - operations.add(operation) - this.pendingSpawnOperationsBySessionId.set(sessionId, operations) - if (opts.agentSessionEnsure) { - this.pendingClaimSpawnOperations.add(operation) - } - const historyRecovery: HistoryRecoveryContext = { - freeze: null, - unreadableSessionId: null, - identityChanged: false - } - try { - return await this.withHistorySpawnLock(sessionId, () => - this.withDaemonRetry(() => - this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) - ) - ) - } finally { - if (historyRecovery.freeze) { - this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) - } - this.pendingClaimSpawnOperations.delete(operation) - operations.delete(operation) - if (operations.size === 0) { - this.pendingSpawnOperationsBySessionId.delete(sessionId) - } - } - } - - private withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { - const wslContext = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId: opts.sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - }) - if ( - opts.attachOnly === true || - (opts.sessionId !== undefined && opts.isNewSession !== true) || - !opts.worktreeId || - opts.historyIsolationEnabled !== true || - (process.platform === 'win32' && !wslContext) - ) { - return opts - } - const env = { ...opts.env } - const preferredShell = wslContext - ? 'bash' - : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' - const shellPath = resolveUnixShellPath(preferredShell) - const historyArgs = [ - env, - opts.worktreeId, - shellPath, - opts.cwd ?? resolveSafePtyDefaultCwd() - ] as const - const result = wslContext - ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) - : injectHistoryEnv(...historyArgs) - if (wslContext) { - injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) - addWslEnvKeys(env, ['HISTFILE', 'fish_history']) - } - logHistoryInjection(opts.worktreeId, result) - return { ...opts, env } - } - - private async doSpawn( - opts: PtySpawnOptions, - operation: PendingDaemonSpawnOperation, - historyRecovery: HistoryRecoveryContext - ): Promise { - if ( - opts.agentSessionEnsure && - this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION - ) { - throw new Error('agent_session_claim_unavailable') - } - const requestedSessionId = opts.sessionId! - // Why: v30 daemons survive upgrades; reject their accidental create result before publication. - const attachOnly = opts.attachOnly === true - const emulateLegacyAttachOnly = - attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - let sessionId = requestedSessionId - let wslDistro = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - })?.distro - const freezeHistory = async (): Promise => { - if (!this.historyManager) { - return - } - if (historyRecovery.freeze?.sessionId === sessionId) { - return - } - if (historyRecovery.freeze) { - this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze) - } - historyRecovery.freeze = await this.historyManager.freezeForRecovery(sessionId) - historyRecovery.unreadableSessionId = null - } - const detectColdRestore = async (options?: { - ignoreCleanEnd?: boolean - }): Promise => { - if (!this.historyReader) { - return null - } - await freezeHistory() - const detection = await this.historyReader.detectColdRestoreState(sessionId, { - ...options, - wslDistro - }) - if (detection.status === 'unreadable') { - historyRecovery.unreadableSessionId = detection.sessionId - return null - } - const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null - if (detection.status === 'restored' && detection.hasUnreadableRecovery) { - historyRecovery.unreadableSessionId = detection.sessionId - } - if (!restoreInfo) { - return null - } - return { - ...restoreInfo, - cwd: - normalizeWslColdRestoreCwd({ - recoveredCwd: restoreInfo.cwd, - requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), - wslDistro - }) ?? '' - } - } - - if (this.killedSessionTombstones.has(sessionId)) { - throw new TerminalKilledError(sessionId) - } - - if (opts.isNewSession) { - await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() - await this.replaceStaleBundleDaemonBeforeNewPty() - await this.replaceSeveredMacTccDaemonBeforeNewPty() - } - - await this.ensureConnected() - // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from - // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop - // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. - if (!this.canDelegateBackgroundToDaemon) { - this.setPtyBackgrounded(sessionId, false) - } - - // Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request. - // Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted. - let restoreInfo: ColdRestoreInfo | null = null - let restoreSkippedForLiveSession = false - const historyProbe = opts.attachOnly - ? undefined - : this.historyReader?.probeRestorableHistory(sessionId) - if (historyProbe && historyProbe.status !== 'none') { - if ((await this.getAppliedSize(sessionId)) !== null) { - restoreSkippedForLiveSession = true - if (this.historyManager && !this.historyManager.hasWriter(sessionId)) { - await detectColdRestore() - restoreInfo = null - } - } else { - restoreInfo = await detectColdRestore() - } - } - let effectiveCwd = restoreInfo?.cwd ?? opts.cwd - let effectiveCols = restoreInfo?.cols ?? opts.cols - let effectiveRows = restoreInfo?.rows ?? opts.rows - - const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false - const isCodexStartupCommand = - recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' - const shouldWaitForShellReady = - isCodexStartupCommand && - shouldUseShellReadyStartupDelivery({ - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery - }) - const shellReadyTimeoutMs = - shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady - ? CODEX_SHELL_READY_TIMEOUT_MS - : undefined - - const requestCreateOrAttach = ( - historySeed: string | undefined, - historySeedTransferId: string | undefined - ) => { - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - const payload = { - sessionId, - cols: effectiveCols, - rows: effectiveRows, - cwd: attachOnly ? undefined : effectiveCwd, - env: attachOnly ? undefined : opts.env, - envToDelete: attachOnly ? undefined : opts.envToDelete, - command: attachOnly ? undefined : opts.command, - startupCommandDelivery: attachOnly ? undefined : opts.startupCommandDelivery, - launchAgent: attachOnly ? undefined : opts.launchAgent, - ...(attachOnly && !emulateLegacyAttachOnly ? { attachOnly: true } : {}), - // Why: without forwarding the override, the daemon falls back to cmd.exe/PowerShell, ignoring the shell the renderer chose; this matches LocalPtyProvider. - shellOverride: attachOnly ? undefined : opts.shellOverride, - terminalWindowsWslDistro: attachOnly ? undefined : opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: attachOnly - ? undefined - : opts.terminalWindowsPowerShellImplementation, - shellReadySupported: attachOnly ? false : shellReadySupported, - ...(!attachOnly && shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}), - ...(historySeed ? { historySeed } : {}), - ...(historySeedTransferId ? { historySeedTransferId } : {}), - ...(this.supportsStartupIngress && !attachOnly && opts.startupIngress - ? { startupIngress: opts.startupIngress } - : {}), - ...(!attachOnly && opts.agentSessionEnsure - ? { agentSessionEnsure: opts.agentSessionEnsure } - : {}) - } - return opts.signal - ? this.client.request( - 'createOrAttach', - payload, - undefined, - opts.signal - ) - : this.client.request('createOrAttach', payload) - } - - const createOrAttach = async ( - historySeedSegments: readonly string[] | null - ): Promise => { - // Why scoped per call: the aliveness-probe retry re-runs this with its own seed, so a first-call - // delivery failure must not force historySeeded=false on a retry that seeded successfully. - let historySeedUnavailable = false - const deliverSeedAndCreate = async (): Promise => { - if (!historySeedSegments || historySeedSegments.length === 0) { - return requestCreateOrAttach(undefined, undefined) - } - const metrics = measureTerminalHistorySeed(historySeedSegments) - if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) { - try { - return await requestCreateOrAttach(historySeedSegments.join(''), undefined) - } catch (error) { - if (!(error instanceof NdjsonLineTooLongError)) { - throw error - } - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - } - if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) { - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - - let transferId: string | undefined - try { - const started = await this.client.request<{ transferId: string }>( - 'startHistorySeedTransfer', - metrics - ) - transferId = started.transferId - let index = 0 - for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) { - await this.client.request('appendHistorySeedTransfer', { transferId, index, data }) - index += 1 - } - await this.client.request('finishHistorySeedTransfer', { transferId }) - } catch (error) { - if (transferId) { - await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {}) - } - if (isDaemonGoneError(error)) { - throw error - } - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - return requestCreateOrAttach(undefined, transferId) - } - const result = await deliverSeedAndCreate() - return historySeedUnavailable && result.historySeeded === undefined - ? { ...result, historySeeded: false } - : result - } - - let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { - const requestedSessionId = sessionId - if ( - opts.agentSessionEnsure && - !isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure) - ) { - // Why: a claim-incapable owner may already have spawned before returning - // a malformed response; retire only this requested session before failing closed. - await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {}) - throw new Error('agent_session_claim_unavailable') - } - sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId - if (requestedSessionId === sessionId) { - return - } - if (historyRecovery.freeze) { - this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) - historyRecovery.freeze = null - } - historyRecovery.unreadableSessionId = null - historyRecovery.identityChanged = true - restoreInfo = null - historySeedSegments = null - } - let result = await createOrAttach(historySeedSegments) - if (emulateLegacyAttachOnly && result.isNew) { - operation.ignoreNextExit = true - await this.client.request('kill', { sessionId: requestedSessionId, immediate: true }) - throw new SessionNotFoundError(requestedSessionId) - } - await adoptSpawnResultSession(result) - // Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner. - this.clearSessionAwaitingDaemonRecovery(requestedSessionId) - this.clearSessionAwaitingDaemonRecovery(sessionId) - const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) - if (exitedResult) { - return exitedResult - } - if (result.incarnationId) { - this.sessionIncarnations.set(sessionId, result.incarnationId) - } - const claimResult = (): Pick | Record => - result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} - const incarnationResult = (): Pick | Record => - result.incarnationId ? { incarnationId: result.incarnationId } : {} - let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro - // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. - wslDistro = providerWslDistro ?? undefined - if (wslDistro) { - this.wslDistrosBySessionId.set(sessionId, wslDistro) - } else if (providerWslDistro === null || result.isNew) { - this.wslDistrosBySessionId.delete(sessionId) - } - const launchIdentity = (): { launchAgent?: NonNullable } => - result.launchAgent ? { launchAgent: result.launchAgent } : {} - - if (effectiveCwd) { - this.initialCwds.set(sessionId, effectiveCwd) - } - - // Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor. - let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null - - // Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data. - const cachedRestore = this.coldRestoreCache.get(sessionId) - if (cachedRestore) { - // Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal. - this.activeSessionIds.add(sessionId) - if (this.historyManager && !historyRecovery.identityChanged) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - if (historyRecovery.unreadableSessionId === sessionId) { - this.historyManager.suspendSession(sessionId, recoveryFreeze) - } else { - this.historyManager.reopenSession(sessionId, recoveryFreeze) - } - } - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - coldRestore: cachedRestore, - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - } - - // Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path. - // Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it. - if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) { - restoreInfo = await detectColdRestore({ ignoreCleanEnd: true }) - historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) { - // Why: the aliveness probe raced with session death, so the first - // create lacked recovery bytes. Replace it before exposing the PTY. - if (result.incarnationId) { - operation.ignoredExitIncarnationIds.add(result.incarnationId) - } - operation.ignoreNextExit = true - await this.client.request('kill', { sessionId, immediate: true }) - effectiveCwd = restoreInfo.cwd - effectiveCols = restoreInfo.cols - effectiveRows = restoreInfo.rows - result = await createOrAttach(historySeedSegments) - await adoptSpawnResultSession(result) - const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) - if (exitedRetryResult) { - return exitedRetryResult - } - if (result.incarnationId) { - this.sessionIncarnations.set(sessionId, result.incarnationId) - } - providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro - wslDistro = providerWslDistro ?? undefined - if (wslDistro) { - this.wslDistrosBySessionId.set(sessionId, wslDistro) - } else if (providerWslDistro === null || result.isNew) { - this.wslDistrosBySessionId.delete(sessionId) - } - pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null - this.initialCwds.set(sessionId, effectiveCwd) - } - } else if ( - !historyRecovery.identityChanged && - !result.isNew && - result.historySeeded === false - ) { - restoreInfo = await detectColdRestore() - historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - } - - const wasAlreadyManaged = this.activeSessionIds.has(sessionId) - this.activeSessionIds.add(sessionId) - const providerSequence = providerSequenceFromCreateOrAttach(result) - - // Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback. - if (restoreInfo && (result.isNew || result.historySeeded === false)) { - const coldRestore = this.buildColdRestorePayload(restoreInfo) - const canReanchorHistory = - !historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true - // Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick. - if (this.historyManager && !historyRecovery.identityChanged) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - if (historyRecovery.unreadableSessionId === sessionId) { - await this.historyManager.openSession(sessionId, { - cwd: effectiveCwd ?? '', - cols: effectiveCols, - rows: effectiveRows, - ...(recoveryFreeze ? { recoveryFreeze } : {}), - quarantineUnreadableRecovery: true - }) - if (this.historyManager.hasWriter(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingLiveCheckpoint.add(sessionId) - this.lastFullCheckpointAt.delete(sessionId) - } - } else if (canReanchorHistory) { - this.historyManager.registerWriter(sessionId, recoveryFreeze) - this.sessionsNeedingFullCheckpoint.add(sessionId) - // Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor. - this.lastFullCheckpointAt.delete(sessionId) - } else { - // Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent. - this.historyManager.suspendSession(sessionId, recoveryFreeze) - } - } - if (coldRestore) { - this.coldRestoreCache.set(sessionId, coldRestore) - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - coldRestore, - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - } - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}) - } - } - - if (this.historyManager && !historyRecovery.identityChanged && result.isNew) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - await this.historyManager.openSession(sessionId, { - cwd: effectiveCwd ?? '', - cols: effectiveCols, - rows: effectiveRows, - ...(recoveryFreeze ? { recoveryFreeze } : {}), - ...(historyRecovery.unreadableSessionId === sessionId - ? { quarantineUnreadableRecovery: true } - : {}) - }) - } else if ( - this.historyManager && - !historyRecovery.identityChanged && - (result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId) - ) { - // Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart. - this.historyManager.suspendSession(sessionId, takeRecoveryFreeze(historyRecovery, sessionId)) - } else if (this.historyManager && !historyRecovery.identityChanged) { - // Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint. - this.historyManager.registerWriter(sessionId, takeRecoveryFreeze(historyRecovery, sessionId)) - if (!wasAlreadyManaged) { - // Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity. - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingContinuityCheckpoint.add(sessionId) - this.lastFullCheckpointAt.delete(sessionId) - } - } - - const isReattach = !result.isNew - if (!isReattach || !result.snapshot) { - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}), - ...(isReattach ? { isReattach: true } : {}) - } - } - - const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot) - const reattachProviderSequence = - typeof reattachSnapshot.outputSequence === 'number' - ? { value: reattachSnapshot.outputSequence, generation: 'continued' as const } - : providerSequence - const isAltScreen = reattachSnapshot.modes.alternateScreen - const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences - const snapshotFrame = reattachSnapshot.snapshotAnsi - const snapshotPayload = snapshotPrefix + snapshotFrame - // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). - // Why known `0` is no longer dropped: the pane tracker must be able to tell - // "the app negotiated nothing" from "this reattach proved nothing". - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( - reattachSnapshot.modes.kittyKeyboardFlags - ) - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - snapshot: snapshotPayload, - snapshotCols: reattachSnapshot.cols, - snapshotRows: reattachSnapshot.rows, - // Why only for an alt frame: normal history remains safe to replay at its capture grid. - ...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi - ? { - snapshotPrefixAnsi: snapshotPrefix, - snapshotFrameAnsi: snapshotFrame, - snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi - } - : {}), - ...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}), - ...(kittyKeyboardFlags !== undefined - ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } - : {}), - isReattach: true, - isAlternateScreen: isAltScreen, - // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. - ...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}), - // Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329). - ...(reattachSnapshot.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi } - : {}) - } - } - - private resultForExitBeforeSpawnReply( - sessionId: string, - result: CreateOrAttachResult, - operation: PendingDaemonSpawnOperation - ): PtySpawnResult | null { - const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some( - (exit) => - !(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) && - (!exit.incarnationId || - !result.incarnationId || - exit.incarnationId === result.incarnationId) - ) - if (!matchingExit) { - return null - } - // Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state. - const exitedResult: PtySpawnResult = { - id: sessionId, - exitedBeforeSpawnReply: true, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - return exitedResult - } - - didExitBeforeSpawnReply(result: PtySpawnResult): boolean { - return result.exitedBeforeSpawnReply === true - } - - async attach(id: string): Promise | void> { - await this.ensureConnected() - if (!this.canDelegateBackgroundToDaemon) { - this.setPtyBackgrounded(id, false) - } - - // Why size-first: attach must ride the session's own geometry — a fixed - // 80×24 here could resize a live agent's TUI — and a null size means the - // daemon cannot prove the session, so refuse rather than risk a create. - const size = await this.getAppliedSize(id) - if (!size) { - throw new SessionNotFoundError(id) - } - const result = await this.client.request('createOrAttach', { - sessionId: id, - cols: size.cols, - rows: size.rows, - attachOnly: true - }) - if (result.isNew) { - // Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn - // instead of publishing a fresh shell as an attach. - await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => { - // Why surface, not swallow: a failed retire leaves an untracked orphan shell. - console.warn('[daemon] attach-only retire of accidental legacy spawn failed', { - sessionId: id, - error - }) - }) - throw new SessionNotFoundError(id) - } - this.clearSessionAwaitingDaemonRecovery(id) - const providerSequence = providerSequenceFromCreateOrAttach(result) - return providerSequence ? { providerSequence } : undefined - } - - hasPty(id: string): boolean { - return this.activeSessionIds.has(id) - } - - async probePtyLiveness(id: string): Promise { - try { - if (!this.getSizeUnsupported && this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION) { - try { - const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( - 'getSize', - { sessionId: id }, - LIVENESS_PROBE_TIMEOUT_MS - ) - return result.size !== null - } catch (error) { - // Why the capability probe rather than the version alone: `getSize` shipped into an - // already-released protocol without a bump, so a daemon can report a version that - // implies support and still reject the request. Ask what it can do, not what its - // number implies — and remember the answer so later probes skip the dead round trip. - if (!isUnknownRequestTypeError(error)) { - throw error - } - this.getSizeUnsupported = true - } - } - // Why: a daemon without `getSize` would otherwise answer `null` forever, and one `null` - // makes the whole owner fan-out unprovable — a dead pane could then never be retired. - // `listSessions` is the same inventory legacy discovery routes by, and has existed since - // the first daemon protocol. Requested directly rather than through `listProcesses` so a - // liveness probe does not publish inventory audit observations as a side effect; both - // rethrow on failure, so either way a dead socket stays `null` instead of reading absent. - const { sessions } = await this.client.request( - 'listSessions', - undefined, - LIVENESS_PROBE_TIMEOUT_MS - ) - return sessions.some((session) => session.sessionId === id && session.isAlive) - } catch { - return null - } - } - - write(id: string, data: string): boolean { - const recoverable = this.prepareWrite(id) - return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable) - } - - async writeWithSettlement(id: string, data: string): Promise { - const recoverable = this.prepareWrite(id) - return this.finishWrite( - id, - await this.client.notifyWithSettlement('write', { sessionId: id, data }), - recoverable - ) - } - - private prepareWrite(id: string): boolean { - this.markSessionDirty(id) - // Why recoverable and not just active: rejecting a write asks the pane to remount, - // which only helps if this endpoint can come back. A legacy adapter has no respawn, - // so its reattach fails and the pane rebuilds empty — losing scrollback the user - // could still read. Keep the pre-existing silent drop for those. - const recoverable = - this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn) - if ( - recoverable && - (this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected()) - ) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.reconnectAfterWriteFailure() - throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) - } - return recoverable - } - - private finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean { - if (!delivered && recoverable) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.reconnectAfterWriteFailure() - throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) - } - return delivered - } - - resize(id: string, cols: number, rows: number): void { - this.markSessionDirty(id) - this.client.notify('resize', { sessionId: id, cols, rows }) - } - - pauseProducer(id: string): void { - if (!this.supportsProducerFlowControl) { - return - } - this.pausedProducerSessionIds.add(id) - this.client.notify('pausePty', { sessionId: id }) - } - - resumeProducer(id: string): void { - this.producerResumesOwedOnReconnect.delete(id) - if (!this.supportsProducerFlowControl) { - return - } - this.pausedProducerSessionIds.delete(id) - this.client.notify('resumePty', { sessionId: id }) - } - - // Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning. - setPtyBackgrounded(id: string, background: boolean): void { - if (!this.supportsProducerFlowControl) { - return - } - // Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream. - // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan - // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never - // retract it, so a TUI exiting while hidden would strand the subscription and the - // next theme flip would inject CSI 997 into its replacement shell. Declining to - // background keeps main's scanner — which emits both facts — authoritative. - const safeBackground = this.canDelegateBackgroundToDaemon && background - if (safeBackground) { - this.backgroundedSessionIds.add(id) - } else { - this.backgroundedSessionIds.delete(id) - } - this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) - } - - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - if (opts.keepHistory && this.disconnectOnlyPromise) { - throw new Error('Cannot keep history after daemon disconnect has started') - } - const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts)) - if (!opts.keepHistory) { - await shutdown - return - } - this.keepHistoryShutdowns.add(shutdown) - try { - await shutdown - } finally { - this.keepHistoryShutdowns.delete(shutdown) - } - } - - private async shutdownWithHistoryLock( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - // Why: shutdown can be the first lazy-client operation after restart; connect - // before killing so a healthy daemon session is not orphaned (#7742). Connect, - // the final-checkpoint wait, and kill all share the caller's one absolute - // deadline, so neither a wedged handshake nor a stalled history write can burn - // the whole teardown budget before the kill even starts. Only the waits are - // bounded — the checkpoint itself stays deadline-free and lossless (STA-4228). - await this.ensureConnected(opts.deadlineMs) - // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. - // Force a final snapshot so wake can restore the pane users left. - if (opts.keepHistory) { - const committed = await this.runExclusiveCheckpoint( - async () => { - await this.checkpointSessions([id], { final: true, teardown: true }) - }, - { callerDeadlineMs: opts.deadlineMs } - ) - // Why throw instead of killing anyway: the snapshot the caller asked us to prove is still - // being written. Killing here would race the wake-time restore source to disk, so report the - // pty unverified and leave it alive — worktree sleep declines to commit it and retries. - if (!committed) { - throw new FinalCheckpointWaitExpiredError(id) - } - const wslDistro = this.wslDistrosBySessionId.get(id) - const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro }) - const detected = detection?.status === 'restored' ? detection.restoreInfo : null - const restoreInfo = detected - ? { - ...detected, - cwd: - normalizeWslColdRestoreCwd({ - recoveredCwd: detected.cwd, - requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), - wslDistro - }) ?? '' - } - : null - const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null - if (coldRestore) { - this.coldRestoreCache.set(id, coldRestore) - if (this.coldRestoreCache.has(id)) { - this.sleepRestoreSessionIds.add(id) - } - // Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority. - this.historyManager?.suspendSession(id) - } else if ( - detection?.status === 'unreadable' || - (detection?.status === 'restored' && detection.hasUnreadableRecovery) - ) { - this.historyManager?.suspendSession(id) - } - } - await this.client.request( - 'kill', - { sessionId: id, immediate: opts.immediate ?? false }, - remainingRequestTimeoutMs(opts.deadlineMs) - ) - this.activeSessionIds.delete(id) - this.clearSessionAwaitingDaemonRecovery(id) - this.dirtySessionVersions.delete(id) - if (!opts.keepHistory) { - this.coldRestoreCache.delete(id) - this.sleepRestoreSessionIds.delete(id) - } - // Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory). - this.sessionsNeedingFullCheckpoint.delete(id) - this.sessionsNeedingLiveCheckpoint.delete(id) - this.sessionsNeedingContinuityCheckpoint.delete(id) - this.overlayDeadlineWarnedSessionIds.delete(id) - this.periodicDeadlineWarnedSessionIds.delete(id) - this.nonFinalAdmissionDeniedSessionIds.delete(id) - this.lastFullCheckpointAt.delete(id) - this.stopCheckpointTimerIfIdle() - this.initialCwds.delete(id) - this.wslDistrosBySessionId.delete(id) - // Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory). - if (this.historyManager && !opts.keepHistory) { - await this.historyManager - .removeSession(id) - .catch((err) => console.warn('[history] removeSession failed:', id, err)) - } - - // Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory. - if (!opts.keepHistory) { - this.killedSessionTombstones.delete(id) - this.killedSessionTombstones.set(id, Date.now()) - if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { - const oldest = this.killedSessionTombstones.keys().next().value - if (oldest) { - this.killedSessionTombstones.delete(oldest) - } - } - } - } - - ackColdRestore(sessionId: string): void { - this.coldRestoreCache.delete(sessionId) - this.sleepRestoreSessionIds.delete(sessionId) - } - - clearTombstone(sessionId: string): void { - this.killedSessionTombstones.delete(sessionId) - } - - private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { - // Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake. - const scrollback = restoreInfo.modes.alternateScreen - ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null - : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi - if (!scrollback) { - return null - } - return { - scrollback, - cwd: restoreInfo.cwd, - cols: restoreInfo.cols, - rows: restoreInfo.rows, - oscLinks: restoreInfo.oscLinks, - ...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {}) - } - } - - async sendSignal(id: string, signal: string): Promise { - await this.client.request('signal', { sessionId: id, signal }) - } - - async getCwd(id: string): Promise { - try { - const result = await this.client.request<{ cwd: string | null }>('getCwd', { - sessionId: id - }) - return result.cwd ?? '' - } catch { - return '' - } - } - - async getInitialCwd(id: string): Promise { - return this.initialCwds.get(id) ?? '' - } - - // Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert. - async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { - try { - const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( - 'getSize', - { sessionId: id } - ) - return result.size ?? null - } catch { - return null - } - } - - async getBufferSnapshot( - id: string, - opts: { scrollbackRows?: number } = {} - ): Promise { - if (!this.supportsAuthoritativeBufferSnapshots) { - return null - } - try { - const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows) - const result = await this.client.request('getSnapshot', { - sessionId: id, - ...(scrollbackRows !== undefined ? { scrollbackRows } : {}) - }) - const snapshot = result.snapshot - // Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket. - if (!snapshot || typeof snapshot.outputSequence !== 'number') { - return null - } - const restored = - this.historyManager && - this.historyReader && - (scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS) - ? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows) - : snapshot - return this.toProviderBufferSnapshot(restored) - } catch { - return null - } - } - - private toProviderBufferSnapshot( - snapshot: NonNullable - ): PtyProviderBufferSnapshot | null { - if (typeof snapshot.outputSequence !== 'number') { - return null - } - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags) - return { - data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, - frameRestoreAnsi: snapshot.frameRestoreAnsi, - scrollbackAnsi: snapshot.scrollbackAnsi, - cols: snapshot.cols, - rows: snapshot.rows, - cwd: snapshot.cwd, - lastTitle: snapshot.lastTitle, - seq: snapshot.outputSequence, - source: 'headless', - oscLinks: snapshot.oscLinks, - alternateScreen: snapshot.modes.alternateScreen, - // Why known `0` is carried too: it proves the app negotiated nothing at - // this boundary, which is a different fact from a source that cannot say. - ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), - ...(snapshot.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } - : {}) - } - } - - private async overlayDurableRestoreSnapshot( - sessionId: string, - liveSnapshot: NonNullable, - scrollbackRows?: number - ): Promise> { - if (!this.historyManager || !this.historyReader) { - return liveSnapshot - } - // Why turn the caller away instead of queueing: this session already has a - // compact in flight whose result a third one would only duplicate, and an - // unbounded queue is how a stalled history filesystem grows without limit. - if (this.checkpointQueue.isSaturated(sessionId)) { - return liveSnapshot - } - // Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts. - // Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work. - if (!this.tryAdmitNonFinalCheckpoint(sessionId)) { - return liveSnapshot - } - // Why per session with a deadline: a reattach is a user click, so it must wait - // on this session's own compact and nothing else. A blown deadline does not - // cancel that compact — it keeps running and still commits — so the fallback - // costs restore depth for this one reattach, never durable history (STA-4173). - return await this.checkpointQueue.runWithDeadline( - sessionId, - async () => { - try { - return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows) - } finally { - this.releaseNonFinalCheckpointAdmission(sessionId) - this.overlayDeadlineWarnedSessionIds.delete(sessionId) - } - }, - DURABLE_HISTORY_OVERLAY_DEADLINE_MS, - liveSnapshot, - { - onDeadline: () => { - if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) { - this.overlayDeadlineWarnedSessionIds.add(sessionId) - console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId) - } - } - } - ) - } - - private tryAdmitNonFinalCheckpoint(sessionId: string): boolean { - if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) { - if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) { - this.nonFinalAdmissionDeniedSessionIds.add(sessionId) - console.warn('[history] non-final checkpoint already in flight:', sessionId) - } - return false - } - if (this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS) { - this.reportNonFinalGlobalAdmissionDenial(sessionId) - return false - } - this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) - this.nonFinalCheckpointAdmissionSessionIds.add(sessionId) - return true - } - - private reportNonFinalGlobalAdmissionDenial(sessionId: string): void { - if (!this.nonFinalGlobalAdmissionWarningActive) { - this.nonFinalGlobalAdmissionWarningActive = true - console.warn('[history] non-final checkpoint global admission limit reached:', sessionId) - } - } - - private releaseNonFinalCheckpointAdmission(sessionId: string): void { - if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) { - this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) - this.nonFinalGlobalAdmissionWarningActive = false - } - } - - private async compactDurableRestoreSnapshot( - sessionId: string, - liveSnapshot: NonNullable, - scrollbackRows?: number - ): Promise> { - if (!this.historyReader) { - return liveSnapshot - } - try { - // Why compact before reading: an independent take/append races the 5s tick, can - // seq-gap the log, and would remount a stale checkpoint over the live window. - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), - requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) - }) - if (checkpoint.checkpoint === 'committed') { - this.sessionsNeedingFullCheckpoint.delete(sessionId) - } - if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) { - return checkpoint.snapshot ?? liveSnapshot - } - if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) { - return checkpoint.snapshot - } - const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { - ignoreCleanEnd: true, - wslDistro: this.wslDistrosBySessionId.get(sessionId) - }) - if (!restoreInfo) { - return liveSnapshot - } - return await buildDurableCheckpointSnapshot({ - liveSnapshot: checkpoint.snapshot, - restoreInfo, - scrollbackRows - }) - } catch (error) { - console.warn('[history] durable snapshot overlay failed:', sessionId, error) - return liveSnapshot - } - } - - async clearBuffer(id: string): Promise { - await this.client.request('clearScrollback', { sessionId: id }) - this.markSessionDirty(id) - } - - acknowledgeDataEvent(_id: string, _charCount: number): void { - // No flow control for daemon-backed terminals - } - - // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. - private hasChildProcessesFromForeground(foregroundProcess: string | null): boolean { - return foregroundProcess !== null && !isShellProcess(foregroundProcess) - } - - async hasChildProcesses(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return true - } - return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id)) - } - - async inspectProcess(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return { foregroundProcess: null, hasChildProcesses: true, unavailable: true } - } - if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { - // Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the - // one call they do support instead of throwing, or completion detection stays dead until recreate. - // Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than - // reading as an idle foreground and dispatching a false completion. - const { foregroundProcess } = await this.client.request<{ - foregroundProcess: string | null - }>('getForegroundProcess', { sessionId: id }) - return { - foregroundProcess, - hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess) - } - } - return this.client.request<{ - foregroundProcess: string | null - hasChildProcesses: boolean - }>('inspectProcess', { sessionId: id }) - } - - async getForegroundProcess(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return null - } - try { - const result = await this.client.request<{ foregroundProcess: string | null }>( - 'getForegroundProcess', - { sessionId: id } - ) - return result.foregroundProcess - } catch { - return null - } - } - - async confirmForegroundProcess(id: string): Promise { - try { - const result = await this.client.request<{ foregroundProcess: string | null }>( - 'confirmForegroundProcess', - { sessionId: id } - ) - return result.foregroundProcess - } catch { - return null - } - } - - async serialize(ids: string[]): Promise { - const sessions: Record = {} - for (const id of ids) { - sessions[id] = { initialCwd: this.initialCwds.get(id) } - } - return JSON.stringify(sessions) - } - - async revive(_state: string): Promise { - // Sessions already live in the daemon — no revival needed - } - - /** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId - * no longer exists, and caches alive session IDs. - * - * IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed - * worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live - * worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false - * orphans. No production caller yet; wire the alias in when it gains one. */ - async reconcileOnStartup(validWorktreeIds: Set): Promise<{ - alive: string[] - killed: string[] - }> { - await this.ensureConnected() - const result = await this.client.request('listSessions', undefined) - - const alive: string[] = [] - const killed: string[] = [] - - for (const session of result.sessions) { - if (!session.isAlive) { - continue - } - // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. - const { worktreeId } = parsePtySessionId(session.sessionId) - - if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { - try { - await this.client.request('kill', { sessionId: session.sessionId }) - } catch { - /* already dead */ - } - killed.push(session.sessionId) - } else { - alive.push(session.sessionId) - // Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data. - this.activeSessionIds.add(session.sessionId) - await this.reconcileLiveSessionHistory(session).catch((err) => - console.warn('[history] live-session reconciliation failed:', session.sessionId, err) - ) - } - } - - return { alive, killed } - } - - private async reconcileLiveSessionHistory(session: SessionInfo): Promise { - const historyManager = this.historyManager - const historyReader = this.historyReader - if (!historyManager || !historyReader) { - return - } - await this.withHistorySpawnLock(session.sessionId, async () => { - if (historyManager.hasWriter(session.sessionId)) { - return - } - const probe = historyReader.probeRestorableHistory(session.sessionId) - if (probe.status === 'unreadable') { - return - } - if (probe.status === 'none') { - await historyManager.openSession(session.sessionId, { - cwd: session.cwd ?? '', - cols: session.cols, - rows: session.rows - }) - } else { - const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId) - try { - const detection = await historyReader.detectColdRestoreState(session.sessionId, { - wslDistro: session.wslDistro ?? undefined - }) - if ( - detection.status === 'unreadable' || - (detection.status === 'restored' && detection.hasUnreadableRecovery) - ) { - historyManager.suspendSession(session.sessionId, recoveryFreeze) - return - } - historyManager.reopenSession(session.sessionId, recoveryFreeze) - } finally { - historyManager.abandonRecoveryFreeze(recoveryFreeze) - } - } - if (historyManager.hasWriter(session.sessionId)) { - this.sessionsNeedingFullCheckpoint.add(session.sessionId) - this.sessionsNeedingContinuityCheckpoint.add(session.sessionId) - this.lastFullCheckpointAt.delete(session.sessionId) - this.markSessionDirty(session.sessionId) - } - }) - } - - async listProcesses(opts?: { deadlineMs?: number }): Promise { - // Why: snapshotted before the request so ids spawned mid-flight can never - // be reconciled away below. - const preRequestActiveIds = new Set(this.activeSessionIds) - try { - // Why: connect + listSessions share the caller's one absolute deadline so a - // wedged handshake cannot burn the whole teardown budget before the list issues. - await this.ensureConnected(opts?.deadlineMs) - const result = await this.client.request( - 'listSessions', - undefined, - remainingRequestTimeoutMs(opts?.deadlineMs) - ) - const admission = new PtyProcessListAdmission() - const processes: PtyProcessInfo[] = [] - const aliveSessionIds = new Set() - for (const session of result.sessions) { - if (!session.isAlive) { - continue - } - aliveSessionIds.add(session.sessionId) - const { worktreeId } = parsePtySessionId(session.sessionId) - processes.push( - admission.admit({ - id: session.sessionId, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), - // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. - cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', - title: 'shell', - ...(worktreeId ? { worktreeId } : {}), - ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), - ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - }) - ) - } - // Why: hasPty reads activeSessionIds, and an exit missed while the socket - // was disconnected otherwise survives an authoritative inventory forever — - // defeating every absence proof built on the cache. - for (const id of preRequestActiveIds) { - if (!aliveSessionIds.has(id)) { - this.activeSessionIds.delete(id) - } - } - this.publishAuditObservation( - recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation) - ) - return processes - } catch (error) { - const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing() - const missingNamedPipe = isMissingWindowsNamedPipeError(error) - this.observeAuditFailure( - missingAuthenticatedToken - ? 'token_missing_after_authenticated_disconnect' - : 'inventory_failed', - this.exactDaemonIncarnation, - [ - ...(missingAuthenticatedToken ? (['token_file'] as const) : []), - ...(missingNamedPipe ? (['windows_named_pipe'] as const) : []) - ], - missingNamedPipe ? 'windows_named_pipe_missing' : undefined - ) - throw error - } - } - - private validatedAgentSessionOwners( - owners: unknown - ): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record { - if (owners === undefined) { - return {} - } - if ( - !Array.isArray(owners) || - owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES || - !owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live') - ) { - throw new Error('agent_session_ownership_unknown') - } - return owners.length > 0 - ? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) } - : {} - } - - // Why: the Manage Sessions panel needs the full SessionInfo (pid, state, - // createdAt) per session for display; listProcesses drops that detail for - // the IPtyProvider contract. Keep both in parallel rather than widening - // the provider surface. - async listSessions(): Promise { - await this.ensureConnected() - const result = await this.client.request('listSessions', undefined) - return result.sessions - .filter((s) => s.isAlive) - .map((session) => ({ - ...session, - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - })) - } - - getActiveSessionIds(): string[] { - return [...this.activeSessionIds] - } - - // Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit - // for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever. - fanoutSyntheticExits(code: number): void { - const ids = [...this.activeSessionIds] - this.activeSessionIds.clear() - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.sessionsNeedingFullCheckpoint.clear() - this.sessionsNeedingLiveCheckpoint.clear() - this.sessionsNeedingContinuityCheckpoint.clear() - this.overlayDeadlineWarnedSessionIds.clear() - this.periodicDeadlineWarnedSessionIds.clear() - this.nonFinalAdmissionDeniedSessionIds.clear() - this.pausedProducerSessionIds.clear() - this.producerResumesOwedOnReconnect.clear() - this.stopCheckpointTimer() - for (const id of ids) { - this.coldRestoreCache.delete(id) - // Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics. - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.exitListeners]) { - listener({ - id, - code, - ...(this.sessionIncarnations.get(id) - ? { incarnationId: this.sessionIncarnations.get(id) } - : {}) - }) - } - this.sessionIncarnations.delete(id) - } - } - - async getDefaultShell(): Promise { - if (process.platform === 'win32') { - return process.env.COMSPEC || 'powershell.exe' - } - return process.env.SHELL || '/bin/zsh' - } - - async getProfiles(): Promise<{ name: string; path: string }[]> { - if (process.platform === 'win32') { - return [ - { name: 'PowerShell', path: 'powershell.exe' }, - { name: 'Command Prompt', path: 'cmd.exe' } - ] - } - const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] - return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) - } - - onData( - callback: (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void - ): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } - } - - onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { - this.backgroundStreamListeners.push(callback) - return () => { - const idx = this.backgroundStreamListeners.indexOf(callback) - if (idx !== -1) { - this.backgroundStreamListeners.splice(idx, 1) - } - } - } - - onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { - return () => {} - } - - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } - - onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { - this.writeUnavailableListeners.push(callback) - return () => { - const idx = this.writeUnavailableListeners.indexOf(callback) - if (idx !== -1) { - this.writeUnavailableListeners.splice(idx, 1) - } - } - } - - private emitWriteUnavailable(id: string): void { - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.writeUnavailableListeners]) { - listener({ id }) - } - } - - dispose(): void { - this.respawnAdoptionClosed = true - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.releasePendingRespawnAdoptionLease() - this.stopCheckpointTimer() - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.sessionsNeedingFullCheckpoint.clear() - this.sessionsNeedingLiveCheckpoint.clear() - this.sessionsNeedingContinuityCheckpoint.clear() - this.overlayDeadlineWarnedSessionIds.clear() - this.periodicDeadlineWarnedSessionIds.clear() - this.nonFinalAdmissionDeniedSessionIds.clear() - this.coldRestoreCache.clear() - this.wslDistrosBySessionId.clear() - this.pausedProducerSessionIds.clear() - this.producerResumesOwedOnReconnect.clear() - this.auditObservationListeners.length = 0 - this.identityChangeListeners.length = 0 - this.removeEventListener?.() - this.removeEventListener = null - // Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions - // cleanly ended so they don't trigger false cold restores. - if (this.historyManager) { - void this.historyManager - .dispose() - .catch((err) => console.warn('[history] dispose failed:', err)) - } - this.client.disconnect() - } - - async establishLifecycleLease(): Promise { - if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { - return - } - // Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit. - await this.client.ensureConnected() - this.recordAuthenticatedIdentity() - } - - // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, - // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. - async disconnectOnly(): Promise { - if (!this.disconnectOnlyPromise) { - this.respawnAdoptionClosed = true - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.releasePendingRespawnAdoptionLease() - this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns]) - } - await this.disconnectOnlyPromise - } - - private async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { - // Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations. - await Promise.allSettled(keepHistoryShutdowns) - this.respawnAdoptionClosed = true - // Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon - // later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs. - await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), { - rescheduleDirty: false - }) - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.coldRestoreCache.clear() - this.wslDistrosBySessionId.clear() - // Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window. - for (const id of this.pausedProducerSessionIds) { - this.client.notify('resumePty', { sessionId: id }) - } - this.pausedProducerSessionIds.clear() - 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's empty; a shared budget keeps this 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 once its auth sockets close and it proves itself empty. - } - } - this.client.disconnect() - } - - private async ensureConnected(deadlineMs?: number): Promise { - try { - // Why: destructive teardown bounds the handshake by its deadline so a wedged - // connect fails fast; undefined keeps the default connect behavior. - await (deadlineMs !== undefined - ? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) - : this.client.ensureConnected()) - } finally { - // Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks. - this.releasePendingRespawnAdoptionLease() - } - this.recordAuthenticatedIdentity() - // Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the - // daemon-side backgrounded set (process state lost with the old daemon) needs a resync. - const isFreshConnection = this.removeEventListener === null - this.setupEventRouting() - this.scheduleCheckpointTimer() - this.flushOwedProducerResumes() - if (isFreshConnection) { - this.resyncBackgroundedSessions() - } - } - - private recordAuthenticatedIdentity(): void { - const current = this.client.getDaemonIdentity() - if (!current) { - return - } - const previous = this.lastAuthenticatedIdentity - if (previous && sameEndpointIdentity(previous, current)) { - return - } - this.lastAuthenticatedIdentity = { ...current } - this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) - if (!previous) { - return - } - const event = { previous: { ...previous }, current: { ...current } } - notifyAuditListeners(this.identityChangeListeners, event) - } - - private async resolveExactDaemonIncarnation( - exactIncarnation: ExactDaemonIncarnation | null - ): Promise { - if ( - !exactIncarnation || - process.platform !== 'linux' || - (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) - ) { - return exactIncarnation - } - const cachedIncarnation = exactDaemonIncarnationForPidRecord( - exactIncarnation.identity, - this.pidRecord - ) - if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { - return cachedIncarnation - } - const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) - this.pidRecord = pidRecord ?? this.pidRecord - return pidRecord?.linuxStartTicks && pidRecord.bootId - ? { - identity: { ...exactIncarnation.identity }, - linuxStartTicks: pidRecord.linuxStartTicks, - bootId: pidRecord.bootId - } - : exactIncarnation - } - - private cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { - if ( - exactIncarnation && - this.lastAuthenticatedIdentity && - sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) - ) { - this.exactDaemonIncarnation = exactIncarnation - } - } - - private async readMatchingPidRecord( - identity: DaemonEndpointIdentity - ): Promise { - if (!this.pidPath) { - return null - } - try { - const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) - return parsed?.pid === identity.pid && - parsed.startedAtMs === identity.startedAtMs && - parsed.launchNonce === identity.launchNonce - ? parsed - : null - } catch { - return null - } - } - - private observeAuditFailure( - trigger: Exclude, - exactIncarnation = this.exactDaemonIncarnation, - additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], - endpointGoneProof?: 'windows_named_pipe_missing' - ): void { - void this.resolveExactDaemonIncarnation(exactIncarnation) - .then((resolvedIncarnation) => { - this.cacheExactDaemonIncarnation(resolvedIncarnation) - return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { - additionalEvidenceSources, - endpointGoneProof - }) - }) - .then((observation) => this.publishAuditObservation(observation)) - .catch(() => {}) - } - - private publishAuditObservation(observation: DaemonAuditObservation): void { - this.lastAuditObservation = observation - this.trackAuditEligibility(observation) - notifyAuditListeners(this.auditObservationListeners, observation) - } - - private resyncBackgroundedSessions(): void { - for (const id of this.backgroundedSessionIds) { - // Harmless no-op for sessions the daemon doesn't know (yet). - this.client.notify('setSessionBackground', { sessionId: id, background: true }) - } - } - - private flushOwedProducerResumes(): void { - if (this.producerResumesOwedOnReconnect.size === 0) { - return - } - for (const id of this.producerResumesOwedOnReconnect) { - // Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency. - this.client.notify('resumePty', { sessionId: id }) - } - this.producerResumesOwedOnReconnect.clear() - } - - private stopCheckpointTimer(): void { - if (!this.checkpointTimer) { - return - } - clearTimeout(this.checkpointTimer) - this.checkpointTimer = null - } - - private stopCheckpointTimerIfIdle(): void { - if (this.dirtySessionVersions.size === 0) { - this.stopCheckpointTimer() - } - } - - private scheduleCheckpointTimer(): void { - if ( - this.checkpointTimer || - !this.historyManager || - !this.supportsCheckpoints || - this.dirtySessionVersions.size === 0 - ) { - return - } - // Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write. - this.checkpointTimer = setTimeout(() => { - this.checkpointTimer = null - // Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes. - if (this.checkpointInFlight) { - this.scheduleCheckpointTimer() - return - } - const checkpoint = this.checkpointDirtySessions() - this.checkpointInFlight = checkpoint - void checkpoint - .finally(() => { - if (this.checkpointInFlight === checkpoint) { - this.checkpointInFlight = null - this.scheduleCheckpointTimer() - } - }) - // Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here. - .catch(() => {}) - }, DaemonPtyAdapter.CHECKPOINT_INTERVAL_MS) - } - - private markSessionDirty(sessionId: string): void { - if (!this.activeSessionIds.has(sessionId)) { - return - } - this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1) - this.scheduleCheckpointTimer() - } - - private async checkpointDirtySessions(): Promise { - if (!this.historyManager || this.dirtySessionVersions.size === 0) { - return - } - // Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces) - // while not dropping writes that arrive mid-checkpoint. - const versions = new Map( - [...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId)) - ) - if (versions.size === 0) { - this.dirtySessionVersions.clear() - this.stopCheckpointTimer() - return - } - const completed = await this.checkpointSessions(versions.keys()) - for (const [sessionId, version] of versions) { - if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) { - this.dirtySessionVersions.delete(sessionId) - } - } - this.stopCheckpointTimerIfIdle() - } - - /** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */ - private async runExclusiveCheckpoint( - operation: () => Promise, - options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {} - ): Promise { - this.stopCheckpointTimer() - // Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together. - const previous = this.checkpointInFlight ?? Promise.resolve() - const checkpoint = previous.catch(() => {}).then(operation) - this.checkpointInFlight = checkpoint - // Why the release rides the checkpoint instead of the caller's await: a caller that walks away - // at its deadline must leave this checkpoint as the tail, so the durable write still runs to - // completion, still commits, and the next waiter still queues behind it (STA-4228). - const settled = checkpoint.then( - () => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty), - (err: unknown) => { - this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty) - throw err - } - ) - if (options.callerDeadlineMs === undefined) { - await settled - return true - } - return await awaitWithinCallerDeadline(settled, options.callerDeadlineMs) - } - - private releaseExclusiveCheckpoint( - checkpoint: Promise, - rescheduleDirty: boolean | undefined - ): void { - if (this.checkpointInFlight === checkpoint) { - this.checkpointInFlight = null - } - this.stopCheckpointTimer() - if (rescheduleDirty !== false) { - this.scheduleCheckpointTimer() - } - } - - // Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the - // detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact. - private async checkpointAllSessions(): Promise { - const completed = await this.checkpointSessions(this.activeSessionIds, { final: true }) - for (const sessionId of completed) { - this.dirtySessionVersions.delete(sessionId) - } - } - - private async checkpointSessions( - sessionIds: Iterable, - opts?: { final?: boolean; teardown?: boolean } - ): Promise> { - const completed = new Set() - if (!this.historyManager) { - return completed - } - const ids = Array.from(sessionIds) - let nextIndex = 0 - - const checkpointNext = async (): Promise => { - for (;;) { - // No worker in this pass awaits abandoned work, so a full admission set cannot open here. - if ( - opts?.final !== true && - this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS - ) { - const deferredSessionId = ids[nextIndex] - if (deferredSessionId !== undefined) { - this.reportNonFinalGlobalAdmissionDenial(deferredSessionId) - } - return - } - const index = nextIndex - nextIndex++ - if (index >= ids.length) { - return - } - const sessionId = ids[index] - await this.checkpointSession(sessionId, { - final: opts?.final === true, - teardown: opts?.teardown === true - }) - .then((result) => { - // Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires. - if (result === 'done') { - completed.add(sessionId) - } - }) - .catch((err) => console.warn('[history] checkpoint failed:', sessionId, err)) - } - } - // Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once. - const workers = Array.from({ length: Math.min(MAX_CONCURRENT_CHECKPOINTS, ids.length) }, () => - checkpointNext() - ) - await Promise.all(workers) - return completed - } - - // Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately. - private isFullCheckpointCoolingDown(sessionId: string): boolean { - const last = this.lastFullCheckpointAt.get(sessionId) - if (last === undefined) { - return false - } - const elapsed = Date.now() - last - // Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window. - return elapsed >= 0 && elapsed < DaemonPtyAdapter.FULL_CHECKPOINT_COOLDOWN_MS - } - - private async checkpointSession( - sessionId: string, - opts: { final: boolean; teardown: boolean } - ): Promise<'done' | 'deferred'> { - // Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and - // deferring there would silently drop what the user left on screen rather than delay it. - if (opts.final) { - return await this.checkpointQueue.run(sessionId, () => - this.writeSessionCheckpoint(sessionId, opts) - ) - } - // Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue - // still commits and the next tick retries this one. Nothing on disk is discarded. - if ( - this.checkpointQueue.isSaturated(sessionId) || - !this.tryAdmitNonFinalCheckpoint(sessionId) - ) { - return 'deferred' - } - const run = async (): Promise<'done' | 'deferred'> => { - try { - return await this.writeSessionCheckpoint(sessionId, opts) - } finally { - this.releaseNonFinalCheckpointAdmission(sessionId) - this.periodicDeadlineWarnedSessionIds.delete(sessionId) - } - } - return await this.checkpointQueue.runWithDeadline( - sessionId, - run, - DaemonPtyAdapter.PERIODIC_CHECKPOINT_DEADLINE_MS, - 'deferred', - { - onDeadline: () => { - if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) { - this.periodicDeadlineWarnedSessionIds.add(sessionId) - console.warn('[history] periodic checkpoint deadline exceeded:', sessionId) - } - }, - onAbandonedRejection: (error) => { - console.warn('[history] checkpoint failed:', sessionId, error) - } - } - ) - } - - // Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry; - // skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole. - private async writeSessionCheckpoint( - sessionId: string, - opts: { final: boolean; teardown: boolean } - ): Promise<'done' | 'deferred'> { - if (!this.supportsIncrementalCheckpoints) { - const result = await this.client.request('getSnapshot', { sessionId }) - if (result.snapshot && this.historyManager) { - const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot) - return checkpoint === 'retryable' ? 'deferred' : 'done' - } - return 'done' - } - if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { - if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) { - return 'deferred' - } - // Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize, - // so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore). - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: opts.teardown, - forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), - requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return 'done' - } - const take = await this.client.request('takePendingOutput', { - sessionId - }) - if (!take) { - return 'done' - } - if (take.overflowed) { - // Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it. - if (this.isFullCheckpointCoolingDown(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: true - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - return 'done' - } - if (take.records.length === 0) { - return 'done' - } - if (!this.historyManager) { - return 'done' - } - const appendResult = await this.historyManager.appendIncrements( - sessionId, - take.seq, - take.records - ) - if (appendResult === 'needs-checkpoint') { - // Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them. - if (this.isFullCheckpointCoolingDown(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: true - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - } - return 'done' - } - - private async takeSnapshotAndCheckpoint( - sessionId: string, - opts: { - teardown: boolean - forceLiveSnapshot?: boolean - requireContinuityProof?: boolean - } - ): Promise { - const take = await this.client.request('takePendingOutput', { - sessionId, - includeSnapshot: true, - teardownSnapshot: opts.teardown - }) - if (take?.snapshot && this.historyManager) { - // Why require drainedRecords: an older daemon still empties the pending - // queue on includeSnapshot but omits the field. Treating absence as [] - // would compact stale disk history and reset the log. - const snapshot = - take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed - ? take.snapshot - : await this.buildDurableHistorySnapshot( - sessionId, - take.snapshot, - [...take.drainedRecords, ...take.records], - { - pendingRecordsAreComplete: take.seq === 1, - ...(opts.requireContinuityProof === true - ? { requiredPreviousPendingOutputSeq: take.seq - 1 } - : {}) - } - ) - const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, { - pendingOutputSeq: take.seq - }) - if (checkpoint === 'retryable') { - // Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that - // failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and - // defeat the log's seq-gap detection. A stale prefix beats an undetectable hole. - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingLiveCheckpoint.add(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - this.markSessionDirty(sessionId) - return { checkpoint, snapshot: take.snapshot } - } - if (checkpoint === 'unavailable') { - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint, snapshot: take.snapshot } - } - this.lastFullCheckpointAt.set(sessionId, Date.now()) - if (take.records.length > 0 && snapshot === take.snapshot) { - // Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail. - await this.historyManager.appendIncrements(sessionId, take.seq, take.records) - } - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint: 'committed', snapshot } - } - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null } - } - - private async buildDurableHistorySnapshot( - sessionId: string, - liveSnapshot: NonNullable, - pendingRecords: TakePendingOutputResult['records'], - opts: { - pendingRecordsAreComplete: boolean - requiredPreviousPendingOutputSeq?: number - } - ): Promise> { - if (!this.historyReader) { - return liveSnapshot - } - try { - const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { - ignoreCleanEnd: true, - wslDistro: this.wslDistrosBySessionId.get(sessionId) - }) - if ( - (!restoreInfo && !opts.pendingRecordsAreComplete) || - (opts.requiredPreviousPendingOutputSeq !== undefined && - restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq) - ) { - console.warn('[history] durable continuity unproven; using live snapshot:', sessionId) - return liveSnapshot - } - return await buildDurableCheckpointSnapshot({ - liveSnapshot, - restoreInfo, - pendingRecords - }) - } catch (error) { - console.warn('[history] durable history rebuild failed:', sessionId, error) - return liveSnapshot - } - } - - // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. - private isRetiredEndpointTokenMissing(): boolean { - return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) - } - - private async withDaemonRetry(fn: () => Promise): Promise { - try { - return await fn() - } catch (err) { - const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing() - if (missingRetiredEndpointToken) { - this.observeAuditFailure( - 'token_missing_after_authenticated_disconnect', - this.exactDaemonIncarnation, - ['token_file'] - ) - } - if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) { - throw err - } - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn().finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - try { - return await fn() - } finally { - // Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn). - this.releasePendingRespawnAdoptionLease() - } - } - } - - private reconnectAfterWriteFailure(): void { - if ( - this.writeRecoveryPromise || - this.writeRecoveryAttempted || - this.respawnAdoptionClosed || - !this.respawnFn - ) { - return - } - this.writeRecoveryAttempted = true - // Why: the dead endpoint took down every session on this daemon. Signal all - // active panes now — while they are still in activeSessionIds, so the - // renderer's liveness gate still reads them live — so background panes - // remount + re-attach alongside the one that was written, instead of being - // left frozen with silently dropped input until each is typed into. - this.notifyActiveSessionsWriteUnavailable() - const recovery = this.withDaemonRetry(() => this.ensureConnected()) - .catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error)) - .finally(() => { - this.releasePendingRespawnAdoptionLease() - if (this.writeRecoveryPromise === recovery) { - this.writeRecoveryPromise = null - } - }) - this.writeRecoveryPromise = recovery - } - - private notifyActiveSessionsWriteUnavailable(): void { - // Snapshot first: a listener that kills a pane would mutate activeSessionIds - // mid-iteration and silently skip the sibling this fan-out exists to reach. - const ids = [...this.activeSessionIds] - for (const id of ids) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.emitWriteUnavailable(id) - } - } - - private clearSessionAwaitingDaemonRecovery(sessionId: string): void { - this.sessionsAwaitingDaemonRecovery.delete(sessionId) - if (this.sessionsAwaitingDaemonRecovery.size === 0) { - this.writeRecoveryAttempted = false - } - } - - private async withHistorySpawnLock( - sessionId: string, - operation: () => Promise - ): Promise { - if (!this.historyManager) { - return await operation() - } - const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve() - let release!: () => void - const current = new Promise((resolve) => { - release = resolve - }) - const tail = previous.then( - () => current, - () => current - ) - this.historySpawnLocks.set(sessionId, tail) - await previous.catch(() => {}) - try { - return await operation() - } finally { - release() - if (this.historySpawnLocks.get(sessionId) === tail) { - this.historySpawnLocks.delete(sessionId) - } - } - } - - private async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { - if (!this.respawnFn) { - return - } - - const health = await getMacDaemonSystemResolverHealth( - this.socketPath, - this.tokenPath, - this.protocolVersion - ) - if (health !== 'unhealthy') { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified' - : `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return - } - - // Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs. - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] macOS system resolver unavailable - respawning daemon', - 'unhealthy_resolver' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - /** Replace a stale packaged daemon only after its live sessions drain. */ - private async replaceStaleBundleDaemonBeforeNewPty(): Promise { - if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) { - return - } - if (!this.staleBundleReplacementPromise) { - this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce( - this.runtimeDir, - this.packagedAppVersion - ).finally(() => { - this.staleBundleReplacementPromise = null - }) - } - await this.staleBundleReplacementPromise - } - - private async replaceStaleBundleDaemonOnce( - runtimeDir: string, - packagedAppVersion: string - ): Promise { - const stale = await isDaemonStaleForCurrentBundle( - runtimeDir, - this.socketPath, - this.tokenPath, - packagedAppVersion, - this.protocolVersion - ) - if (!stale) { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified' - : `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return - } - - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] Packaged daemon is stale - respawning from the current app bundle', - 'stale_bundle' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - /** Replace a TCC-severed daemon only after its live sessions drain. */ - private async replaceSeveredMacTccDaemonBeforeNewPty(): Promise { - // Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS. - if (!this.respawnFn || !this.runtimeDir) { - return - } - - const health = await getMacDaemonTccAttributionHealth( - this.runtimeDir, - this.socketPath, - this.tokenPath, - this.protocolVersion - ) - if (health !== 'severed') { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified' - : `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready` - ) - return - } - - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] macOS TCC attribution severed - respawning daemon under the current app binary', - 'severed_tcc_attribution' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - private async getDaemonLiveSessionCount(): Promise { - try { - await this.client.ensureConnected() - const result = await this.client.request('listSessions', undefined) - return result.sessions.filter((session) => session.isAlive).length - } catch { - return null - } - } - - private emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.backgroundStreamListeners]) { - listener(payload) - } - } - - private async doRespawn( - message = '[daemon] Daemon died — respawning', - reason: DaemonRespawnReason = 'daemon_died' - ): Promise { - console.warn(message) - this.removeEventListener?.() - this.removeEventListener = null - this.client.disconnect() - const releaseAdoptionLease = await this.respawnFn!(reason) - if (this.respawnAdoptionClosed) { - // Why: app teardown may win mid-respawn; 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 { +export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyProvider { + protected setupEventRouting(): void { if (this.removeEventListener) { return } @@ -2927,12 +50,8 @@ export class DaemonPtyAdapter implements IPtyProvider { : { sequenceChars: event.payload.sequenceChars }) }) } else if (event.event === 'transientFact') { - // Why (#9993): belt-and-braces behind the setPtyBackgrounded gate. A pre-v29 - // daemon is never asked to background, so it should emit no transient facts at - // all — but one preserved across a reconnect could still have a stale relay - // tracker. An unretractable subscribe is the harmful direction, so drop it. - // An unsubscribe is always forwarded: retiring a subscription main registered - // can only ever help, never strand one. + // Why (#9993): a preserved pre-v29 daemon can retain a stale relay tracker. Its + // unretractable subscribe is harmful; an unsubscribe is always safe to forward. if ( event.payload.kind === '2031-subscribe' && !supportsMode2031UnsubscribeFact(this.protocolVersion) @@ -2971,21 +90,18 @@ export class DaemonPtyAdapter implements IPtyProvider { this.activeSessionIds.delete(event.sessionId) this.clearSessionAwaitingDaemonRecovery(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) - // Why: a reused sessionId must not inherit the dead session's owed resume (stray resumePty) or backgrounded/thinned state. this.pausedProducerSessionIds.delete(event.sessionId) this.producerResumesOwedOnReconnect.delete(event.sessionId) this.backgroundedSessionIds.delete(event.sessionId) if (!this.sleepRestoreSessionIds.has(event.sessionId)) { this.coldRestoreCache.delete(event.sessionId) } - // Why: an exited session can't be checkpointed again; clearing its pending-full flag prevents a permanent leak. this.sessionsNeedingFullCheckpoint.delete(event.sessionId) this.sessionsNeedingLiveCheckpoint.delete(event.sessionId) this.sessionsNeedingContinuityCheckpoint.delete(event.sessionId) this.overlayDeadlineWarnedSessionIds.delete(event.sessionId) this.periodicDeadlineWarnedSessionIds.delete(event.sessionId) this.nonFinalAdmissionDeniedSessionIds.delete(event.sessionId) - // Why: a reused sessionId (renderer respawns a persisted ptyId) must not inherit the dead session's snapshot cooldown. this.lastFullCheckpointAt.delete(event.sessionId) this.stopCheckpointTimerIfIdle() if (this.historyManager) { @@ -3019,104 +135,3 @@ export class DaemonPtyAdapter implements IPtyProvider { return result.appliedSeq } } - -function sameEndpointIdentity( - left: DaemonEndpointIdentity, - right: DaemonEndpointIdentity -): boolean { - return ( - left.pid === right.pid && - left.startedAtMs === right.startedAtMs && - left.launchNonce === right.launchNonce - ) -} - -function exactDaemonIncarnationForPidRecord( - identity: DaemonEndpointIdentity, - pidRecord: ParsedDaemonPid | null -): ExactDaemonIncarnation { - return { - identity: { ...identity }, - ...(process.platform === 'linux' && - pidRecord?.pid === identity.pid && - pidRecord.startedAtMs === identity.startedAtMs && - pidRecord.launchNonce === identity.launchNonce && - pidRecord.linuxStartTicks && - pidRecord.bootId - ? { - linuxStartTicks: pidRecord.linuxStartTicks, - bootId: pidRecord.bootId - } - : {}) - } -} - -function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { - if (!pidPath) { - return null - } - try { - return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) - } catch { - return null - } -} - -function removeListener(listeners: T[], listener: T): void { - const index = listeners.indexOf(listener) - if (index !== -1) { - listeners.splice(index, 1) - } -} - -function notifyAuditListeners(listeners: readonly ((value: T) => void)[], value: T): void { - for (const listener of listeners.slice()) { - try { - listener(value) - } catch { - // Audit observers cannot affect daemon operations. - } - } -} - -/** - * Narrow on purpose: only the daemon's own reply for a request type it does not implement. - * A transient failure must stay unproven rather than be mistaken for a missing capability. - * The server throws `Unknown request type: `; the client rejects with that text, which - * `addNodePtyRecoveryHint` only ever prepends to. - */ -function isUnknownRequestTypeError(err: unknown): boolean { - return err instanceof Error && err.message.includes('Unknown request type') -} - -// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); -// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. -/** Exported so a test can pin it against the server's refusal wording, which it must match. */ -export function isDaemonGoneError(err: unknown): boolean { - if (!(err instanceof Error)) { - return false - } - const errno = err as NodeJS.ErrnoException - if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') { - return true - } - const msg = err.message - return ( - msg === 'Connection lost' || - msg === 'Not connected' || - msg === 'Hello response timed out' || - // Both the daemon's own drain refusal and the client's wedged-daemon signal. - msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE || - // Why retry: the daemon refused because the endpoint now resolves elsewhere. Reconnecting - // reaches whoever owns it; surfacing this to the user would strand the request instead. - msg === DAEMON_ENDPOINT_LOST_MESSAGE - ) -} - -function isMissingWindowsNamedPipeError(err: unknown): boolean { - if (process.platform !== 'win32' || !(err instanceof Error)) { - return false - } - const errno = err as NodeJS.ErrnoException - return errno.code === 'ENOENT' && errno.syscall === 'connect' -} diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts new file mode 100644 index 00000000000..cf65d85fa61 --- /dev/null +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -0,0 +1,202 @@ +import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import { DaemonPtySessionControl } from './daemon-pty-session-control' +import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' +import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window' +import type { GetSnapshotResult } from './types' +import type { PtyProviderBufferSnapshot } from '../providers/types' + +const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000 + +export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { + async getBufferSnapshot( + id: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + if (!this.supportsAuthoritativeBufferSnapshots) { + return null + } + try { + const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows) + const result = await this.client.request('getSnapshot', { + sessionId: id, + ...(scrollbackRows !== undefined ? { scrollbackRows } : {}) + }) + const snapshot = result.snapshot + // Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket. + if (!snapshot || typeof snapshot.outputSequence !== 'number') { + return null + } + const restored = + this.historyManager && + this.historyReader && + (scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS) + ? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows) + : snapshot + return this.toProviderBufferSnapshot(restored) + } catch { + return null + } + } + + protected toProviderBufferSnapshot( + snapshot: NonNullable + ): PtyProviderBufferSnapshot | null { + if (typeof snapshot.outputSequence !== 'number') { + return null + } + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags) + return { + data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + frameRestoreAnsi: snapshot.frameRestoreAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + cols: snapshot.cols, + rows: snapshot.rows, + cwd: snapshot.cwd, + lastTitle: snapshot.lastTitle, + seq: snapshot.outputSequence, + source: 'headless', + oscLinks: snapshot.oscLinks, + alternateScreen: snapshot.modes.alternateScreen, + // Why known `0` is carried too: it proves the app negotiated nothing at + // this boundary, which is a different fact from a source that cannot say. + ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}) + } + } + + protected async overlayDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> { + if (!this.historyManager || !this.historyReader) { + return liveSnapshot + } + // Why turn the caller away instead of queueing: this session already has a + // compact in flight whose result a third one would only duplicate, and an + // unbounded queue is how a stalled history filesystem grows without limit. + if (this.checkpointQueue.isSaturated(sessionId)) { + return liveSnapshot + } + // Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts. + // Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work. + if (!this.tryAdmitNonFinalCheckpoint(sessionId)) { + return liveSnapshot + } + // Why per session with a deadline: a reattach is a user click, so it must wait + // on this session's own compact and nothing else. A blown deadline does not + // cancel that compact — it keeps running and still commits — so the fallback + // costs restore depth for this one reattach, never durable history (STA-4173). + return await this.checkpointQueue.runWithDeadline( + sessionId, + async () => { + try { + return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows) + } finally { + this.releaseNonFinalCheckpointAdmission(sessionId) + this.overlayDeadlineWarnedSessionIds.delete(sessionId) + } + }, + DURABLE_HISTORY_OVERLAY_DEADLINE_MS, + liveSnapshot, + { + onDeadline: () => { + if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) { + this.overlayDeadlineWarnedSessionIds.add(sessionId) + console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId) + } + } + } + ) + } + + protected tryAdmitNonFinalCheckpoint(sessionId: string): boolean { + if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) { + if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) { + this.nonFinalAdmissionDeniedSessionIds.add(sessionId) + console.warn('[history] non-final checkpoint already in flight:', sessionId) + } + return false + } + if ( + this.nonFinalCheckpointAdmissionSessionIds.size >= + (this.constructor as typeof DaemonPtyBufferSnapshots).MAX_CONCURRENT_CHECKPOINTS + ) { + this.reportNonFinalGlobalAdmissionDenial(sessionId) + return false + } + this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) + this.nonFinalCheckpointAdmissionSessionIds.add(sessionId) + return true + } + + protected reportNonFinalGlobalAdmissionDenial(sessionId: string): void { + if (!this.nonFinalGlobalAdmissionWarningActive) { + this.nonFinalGlobalAdmissionWarningActive = true + console.warn('[history] non-final checkpoint global admission limit reached:', sessionId) + } + } + + protected releaseNonFinalCheckpointAdmission(sessionId: string): void { + if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) { + this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) + this.nonFinalGlobalAdmissionWarningActive = false + } + } + + protected async compactDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> { + if (!this.historyReader) { + return liveSnapshot + } + try { + // Why compact before reading: an independent take/append races the 5s tick, can + // seq-gap the log, and would remount a stale checkpoint over the live window. + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), + requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) + }) + if (checkpoint.checkpoint === 'committed') { + this.sessionsNeedingFullCheckpoint.delete(sessionId) + } + if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) { + return checkpoint.snapshot ?? liveSnapshot + } + if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) { + return checkpoint.snapshot + } + const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { + ignoreCleanEnd: true, + wslDistro: this.wslDistrosBySessionId.get(sessionId) + }) + if (!restoreInfo) { + return liveSnapshot + } + return await buildDurableCheckpointSnapshot({ + liveSnapshot: checkpoint.snapshot, + restoreInfo, + scrollbackRows + }) + } catch (error) { + console.warn('[history] durable snapshot overlay failed:', sessionId, error) + return liveSnapshot + } + } + + async clearBuffer(id: string): Promise { + await this.client.request('clearScrollback', { sessionId: id }) + this.markSessionDirty(id) + } + + acknowledgeDataEvent(_id: string, _charCount: number): void { + // No flow control for daemon-backed terminals + } +} diff --git a/src/main/daemon/daemon-pty-checkpoint-persistence.ts b/src/main/daemon/daemon-pty-checkpoint-persistence.ts new file mode 100644 index 00000000000..ed8ffd3f26f --- /dev/null +++ b/src/main/daemon/daemon-pty-checkpoint-persistence.ts @@ -0,0 +1,192 @@ +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import { DaemonPtyCheckpointScheduler } from './daemon-pty-checkpoint-scheduler' +import type { SnapshotCheckpointResult } from './daemon-pty-runtime-state' +import type { GetSnapshotResult, TakePendingOutputResult } from './types' + +export abstract class DaemonPtyCheckpointPersistence extends DaemonPtyCheckpointScheduler { + // Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry; + // skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole. + protected async writeSessionCheckpoint( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> { + if (!this.supportsIncrementalCheckpoints) { + const result = await this.client.request('getSnapshot', { sessionId }) + if (result.snapshot && this.historyManager) { + const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot) + return checkpoint === 'retryable' ? 'deferred' : 'done' + } + return 'done' + } + if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { + if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) { + return 'deferred' + } + // Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize, + // so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore). + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: opts.teardown, + forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), + requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return 'done' + } + const take = await this.client.request('takePendingOutput', { + sessionId + }) + if (!take) { + return 'done' + } + if (take.overflowed) { + // Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it. + if (this.isFullCheckpointCoolingDown(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: true + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + return 'done' + } + if (take.records.length === 0) { + return 'done' + } + if (!this.historyManager) { + return 'done' + } + const appendResult = await this.historyManager.appendIncrements( + sessionId, + take.seq, + take.records + ) + if (appendResult === 'needs-checkpoint') { + // Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them. + if (this.isFullCheckpointCoolingDown(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: true + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + } + return 'done' + } + + protected async takeSnapshotAndCheckpoint( + sessionId: string, + opts: { + teardown: boolean + forceLiveSnapshot?: boolean + requireContinuityProof?: boolean + } + ): Promise { + const take = await this.client.request('takePendingOutput', { + sessionId, + includeSnapshot: true, + teardownSnapshot: opts.teardown + }) + if (take?.snapshot && this.historyManager) { + // Why require drainedRecords: an older daemon still empties the pending + // queue on includeSnapshot but omits the field. Treating absence as [] + // would compact stale disk history and reset the log. + const snapshot = + take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed + ? take.snapshot + : await this.buildDurableHistorySnapshot( + sessionId, + take.snapshot, + [...take.drainedRecords, ...take.records], + { + pendingRecordsAreComplete: take.seq === 1, + ...(opts.requireContinuityProof === true + ? { requiredPreviousPendingOutputSeq: take.seq - 1 } + : {}) + } + ) + const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, { + pendingOutputSeq: take.seq + }) + if (checkpoint === 'retryable') { + // Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that + // failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and + // defeat the log's seq-gap detection. A stale prefix beats an undetectable hole. + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingLiveCheckpoint.add(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + this.markSessionDirty(sessionId) + return { checkpoint, snapshot: take.snapshot } + } + if (checkpoint === 'unavailable') { + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint, snapshot: take.snapshot } + } + this.lastFullCheckpointAt.set(sessionId, Date.now()) + if (take.records.length > 0 && snapshot === take.snapshot) { + // Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail. + await this.historyManager.appendIncrements(sessionId, take.seq, take.records) + } + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint: 'committed', snapshot } + } + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null } + } + + protected async buildDurableHistorySnapshot( + sessionId: string, + liveSnapshot: NonNullable, + pendingRecords: TakePendingOutputResult['records'], + opts: { + pendingRecordsAreComplete: boolean + requiredPreviousPendingOutputSeq?: number + } + ): Promise> { + if (!this.historyReader) { + return liveSnapshot + } + try { + const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { + ignoreCleanEnd: true, + wslDistro: this.wslDistrosBySessionId.get(sessionId) + }) + if ( + (!restoreInfo && !opts.pendingRecordsAreComplete) || + (opts.requiredPreviousPendingOutputSeq !== undefined && + restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq) + ) { + console.warn('[history] durable continuity unproven; using live snapshot:', sessionId) + return liveSnapshot + } + return await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo, + pendingRecords + }) + } catch (error) { + console.warn('[history] durable history rebuild failed:', sessionId, error) + return liveSnapshot + } + } +} diff --git a/src/main/daemon/daemon-pty-checkpoint-scheduler.ts b/src/main/daemon/daemon-pty-checkpoint-scheduler.ts new file mode 100644 index 00000000000..56561f88a6c --- /dev/null +++ b/src/main/daemon/daemon-pty-checkpoint-scheduler.ts @@ -0,0 +1,251 @@ +import { awaitDaemonWorkWithinCallerDeadline } from './daemon-request-deadline' +import { DaemonPtyConnectionLifecycle } from './daemon-pty-connection-lifecycle' + +export abstract class DaemonPtyCheckpointScheduler extends DaemonPtyConnectionLifecycle { + protected stopCheckpointTimer(): void { + if (!this.checkpointTimer) { + return + } + clearTimeout(this.checkpointTimer) + this.checkpointTimer = null + } + + protected stopCheckpointTimerIfIdle(): void { + if (this.dirtySessionVersions.size === 0) { + this.stopCheckpointTimer() + } + } + + protected scheduleCheckpointTimer(): void { + if ( + this.checkpointTimer || + !this.historyManager || + !this.supportsCheckpoints || + this.dirtySessionVersions.size === 0 + ) { + return + } + // Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write. + this.checkpointTimer = setTimeout( + () => { + this.checkpointTimer = null + // Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes. + if (this.checkpointInFlight) { + this.scheduleCheckpointTimer() + return + } + const checkpoint = this.checkpointDirtySessions() + this.checkpointInFlight = checkpoint + void checkpoint + .finally(() => { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + this.scheduleCheckpointTimer() + } + }) + // Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here. + .catch(() => {}) + }, + (this.constructor as typeof DaemonPtyCheckpointScheduler).CHECKPOINT_INTERVAL_MS + ) + } + + protected markSessionDirty(sessionId: string): void { + if (!this.activeSessionIds.has(sessionId)) { + return + } + this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1) + this.scheduleCheckpointTimer() + } + + protected async checkpointDirtySessions(): Promise { + if (!this.historyManager || this.dirtySessionVersions.size === 0) { + return + } + // Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces) + // while not dropping writes that arrive mid-checkpoint. + const versions = new Map( + [...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId)) + ) + if (versions.size === 0) { + this.dirtySessionVersions.clear() + this.stopCheckpointTimer() + return + } + const completed = await this.checkpointSessions(versions.keys()) + for (const [sessionId, version] of versions) { + if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) { + this.dirtySessionVersions.delete(sessionId) + } + } + this.stopCheckpointTimerIfIdle() + } + + /** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */ + protected async runExclusiveCheckpoint( + operation: () => Promise, + options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {} + ): Promise { + this.stopCheckpointTimer() + // Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together. + const previous = this.checkpointInFlight ?? Promise.resolve() + const checkpoint = previous.catch(() => {}).then(operation) + this.checkpointInFlight = checkpoint + // Why the release rides the checkpoint instead of the caller's await: a caller that walks away + // at its deadline must leave this checkpoint as the tail, so the durable write still runs to + // completion, still commits, and the next waiter still queues behind it (STA-4228). + const settled = checkpoint.then( + () => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty), + (err: unknown) => { + this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty) + throw err + } + ) + if (options.callerDeadlineMs === undefined) { + await settled + return true + } + return await awaitDaemonWorkWithinCallerDeadline(settled, options.callerDeadlineMs) + } + + protected releaseExclusiveCheckpoint( + checkpoint: Promise, + rescheduleDirty: boolean | undefined + ): void { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + } + this.stopCheckpointTimer() + if (rescheduleDirty !== false) { + this.scheduleCheckpointTimer() + } + } + + // Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the + // detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact. + protected async checkpointAllSessions(): Promise { + const completed = await this.checkpointSessions(this.activeSessionIds, { final: true }) + for (const sessionId of completed) { + this.dirtySessionVersions.delete(sessionId) + } + } + + protected async checkpointSessions( + sessionIds: Iterable, + opts?: { final?: boolean; teardown?: boolean } + ): Promise> { + const completed = new Set() + if (!this.historyManager) { + return completed + } + const ids = Array.from(sessionIds) + let nextIndex = 0 + + const checkpointNext = async (): Promise => { + for (;;) { + // No worker in this pass awaits abandoned work, so a full admission set cannot open here. + if ( + opts?.final !== true && + this.nonFinalCheckpointAdmissionSessionIds.size >= + (this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS + ) { + const deferredSessionId = ids[nextIndex] + if (deferredSessionId !== undefined) { + this.reportNonFinalGlobalAdmissionDenial(deferredSessionId) + } + return + } + const index = nextIndex + nextIndex++ + if (index >= ids.length) { + return + } + const sessionId = ids[index] + await this.checkpointSession(sessionId, { + final: opts?.final === true, + teardown: opts?.teardown === true + }) + .then((result) => { + // Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires. + if (result === 'done') { + completed.add(sessionId) + } + }) + .catch((err) => console.warn('[history] checkpoint failed:', sessionId, err)) + } + } + // Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once. + const workers = Array.from( + { + length: Math.min( + (this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS, + ids.length + ) + }, + () => checkpointNext() + ) + await Promise.all(workers) + return completed + } + + // Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately. + protected isFullCheckpointCoolingDown(sessionId: string): boolean { + const last = this.lastFullCheckpointAt.get(sessionId) + if (last === undefined) { + return false + } + const elapsed = Date.now() - last + // Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window. + return ( + elapsed >= 0 && + elapsed < + (this.constructor as typeof DaemonPtyCheckpointScheduler).FULL_CHECKPOINT_COOLDOWN_MS + ) + } + + protected async checkpointSession( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> { + // Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and + // deferring there would silently drop what the user left on screen rather than delay it. + if (opts.final) { + return await this.checkpointQueue.run(sessionId, () => + this.writeSessionCheckpoint(sessionId, opts) + ) + } + // Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue + // still commits and the next tick retries this one. Nothing on disk is discarded. + if ( + this.checkpointQueue.isSaturated(sessionId) || + !this.tryAdmitNonFinalCheckpoint(sessionId) + ) { + return 'deferred' + } + const run = async (): Promise<'done' | 'deferred'> => { + try { + return await this.writeSessionCheckpoint(sessionId, opts) + } finally { + this.releaseNonFinalCheckpointAdmission(sessionId) + this.periodicDeadlineWarnedSessionIds.delete(sessionId) + } + } + return await this.checkpointQueue.runWithDeadline( + sessionId, + run, + (this.constructor as typeof DaemonPtyCheckpointScheduler).PERIODIC_CHECKPOINT_DEADLINE_MS, + 'deferred', + { + onDeadline: () => { + if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) { + this.periodicDeadlineWarnedSessionIds.add(sessionId) + console.warn('[history] periodic checkpoint deadline exceeded:', sessionId) + } + }, + onAbandonedRejection: (error) => { + console.warn('[history] checkpoint failed:', sessionId, error) + } + } + ) + } +} diff --git a/src/main/daemon/daemon-pty-connection-lifecycle.ts b/src/main/daemon/daemon-pty-connection-lifecycle.ts new file mode 100644 index 00000000000..496961dff16 --- /dev/null +++ b/src/main/daemon/daemon-pty-connection-lifecycle.ts @@ -0,0 +1,156 @@ +import { readFile } from 'node:fs/promises' +import { + classifyDaemonAuditFailure, + type DaemonAuditObservation, + type DaemonAuditTrigger +} from './daemon-audit-classifier' +import { + exactDaemonIncarnationForPidRecord, + sameEndpointIdentity +} from './daemon-endpoint-incarnation' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { notifyDaemonAuditListeners } from './daemon-listener-registry' +import { DaemonPtyEventSubscriptions } from './daemon-pty-event-subscriptions' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' + +export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscriptions { + protected async ensureConnected(deadlineMs?: number): Promise { + try { + // Why: destructive teardown bounds the handshake by its deadline so a wedged + // connect fails fast; undefined keeps the default connect behavior. + await (deadlineMs !== undefined + ? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) + : this.client.ensureConnected()) + } finally { + // Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks. + this.releasePendingRespawnAdoptionLease() + } + this.recordAuthenticatedIdentity() + // Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the + // daemon-side backgrounded set (process state lost with the old daemon) needs a resync. + const isFreshConnection = this.removeEventListener === null + this.setupEventRouting() + this.scheduleCheckpointTimer() + this.flushOwedProducerResumes() + if (isFreshConnection) { + this.resyncBackgroundedSessions() + } + } + + protected recordAuthenticatedIdentity(): void { + const current = this.client.getDaemonIdentity() + if (!current) { + return + } + const previous = this.lastAuthenticatedIdentity + if (previous && sameEndpointIdentity(previous, current)) { + return + } + this.lastAuthenticatedIdentity = { ...current } + this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) + if (!previous) { + return + } + const event = { previous: { ...previous }, current: { ...current } } + notifyDaemonAuditListeners(this.identityChangeListeners, event) + } + + protected async resolveExactDaemonIncarnation( + exactIncarnation: ExactDaemonIncarnation | null + ): Promise { + if ( + !exactIncarnation || + process.platform !== 'linux' || + (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) + ) { + return exactIncarnation + } + const cachedIncarnation = exactDaemonIncarnationForPidRecord( + exactIncarnation.identity, + this.pidRecord + ) + if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { + return cachedIncarnation + } + const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) + this.pidRecord = pidRecord ?? this.pidRecord + return pidRecord?.linuxStartTicks && pidRecord.bootId + ? { + identity: { ...exactIncarnation.identity }, + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : exactIncarnation + } + + protected cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { + if ( + exactIncarnation && + this.lastAuthenticatedIdentity && + sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) + ) { + this.exactDaemonIncarnation = exactIncarnation + } + } + + protected async readMatchingPidRecord( + identity: DaemonEndpointIdentity + ): Promise { + if (!this.pidPath) { + return null + } + try { + const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) + return parsed?.pid === identity.pid && + parsed.startedAtMs === identity.startedAtMs && + parsed.launchNonce === identity.launchNonce + ? parsed + : null + } catch { + return null + } + } + + protected observeAuditFailure( + trigger: Exclude, + exactIncarnation = this.exactDaemonIncarnation, + additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], + endpointGoneProof?: 'windows_named_pipe_missing' + ): void { + void this.resolveExactDaemonIncarnation(exactIncarnation) + .then((resolvedIncarnation) => { + this.cacheExactDaemonIncarnation(resolvedIncarnation) + return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { + additionalEvidenceSources, + endpointGoneProof + }) + }) + .then((observation) => this.publishAuditObservation(observation)) + .catch(() => {}) + } + + protected publishAuditObservation(observation: DaemonAuditObservation): void { + this.lastAuditObservation = observation + this.trackAuditEligibility(observation) + notifyDaemonAuditListeners(this.auditObservationListeners, observation) + } + + protected resyncBackgroundedSessions(): void { + for (const id of this.backgroundedSessionIds) { + // Harmless no-op for sessions the daemon doesn't know (yet). + this.client.notify('setSessionBackground', { sessionId: id, background: true }) + } + } + + protected flushOwedProducerResumes(): void { + if (this.producerResumesOwedOnReconnect.size === 0) { + return + } + for (const id of this.producerResumesOwedOnReconnect) { + // Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency. + this.client.notify('resumePty', { sessionId: id }) + } + this.producerResumesOwedOnReconnect.clear() + } +} diff --git a/src/main/daemon/daemon-pty-daemon-recovery.ts b/src/main/daemon/daemon-pty-daemon-recovery.ts new file mode 100644 index 00000000000..73ef2591be6 --- /dev/null +++ b/src/main/daemon/daemon-pty-daemon-recovery.ts @@ -0,0 +1,290 @@ +import { existsSync } from 'node:fs' +import { getMacDaemonSystemResolverHealth } from './daemon-health' +import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' +import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' +import { isDaemonGoneError } from './daemon-endpoint-errors' +import { DaemonPtyCheckpointPersistence } from './daemon-pty-checkpoint-persistence' +import type { DaemonRespawnReason } from './daemon-pty-runtime-state' +import type { ListSessionsResult } from './types' +import type { PtyBackgroundStreamEvent } from '../providers/types' + +export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence { + // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. + protected isRetiredEndpointTokenMissing(): boolean { + return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) + } + + protected async withDaemonRetry(fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing() + if (missingRetiredEndpointToken) { + this.observeAuditFailure( + 'token_missing_after_authenticated_disconnect', + this.exactDaemonIncarnation, + ['token_file'] + ) + } + if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) { + throw err + } + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn().finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + try { + return await fn() + } finally { + // Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn). + this.releasePendingRespawnAdoptionLease() + } + } + } + + protected reconnectAfterWriteFailure(): void { + if ( + this.writeRecoveryPromise || + this.writeRecoveryAttempted || + this.respawnAdoptionClosed || + !this.respawnFn + ) { + return + } + this.writeRecoveryAttempted = true + // Why: the dead endpoint took down every session on this daemon. Signal all + // active panes now — while they are still in activeSessionIds, so the + // renderer's liveness gate still reads them live — so background panes + // remount + re-attach alongside the one that was written, instead of being + // left frozen with silently dropped input until each is typed into. + this.notifyActiveSessionsWriteUnavailable() + const recovery = this.withDaemonRetry(() => this.ensureConnected()) + .catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error)) + .finally(() => { + this.releasePendingRespawnAdoptionLease() + if (this.writeRecoveryPromise === recovery) { + this.writeRecoveryPromise = null + } + }) + this.writeRecoveryPromise = recovery + } + + protected notifyActiveSessionsWriteUnavailable(): void { + // Snapshot first: a listener that kills a pane would mutate activeSessionIds + // mid-iteration and silently skip the sibling this fan-out exists to reach. + const ids = [...this.activeSessionIds] + for (const id of ids) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.emitWriteUnavailable(id) + } + } + + protected clearSessionAwaitingDaemonRecovery(sessionId: string): void { + this.sessionsAwaitingDaemonRecovery.delete(sessionId) + if (this.sessionsAwaitingDaemonRecovery.size === 0) { + this.writeRecoveryAttempted = false + } + } + + protected async withHistorySpawnLock( + sessionId: string, + operation: () => Promise + ): Promise { + if (!this.historyManager) { + return await operation() + } + const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + this.historySpawnLocks.set(sessionId, tail) + await previous.catch(() => {}) + try { + return await operation() + } finally { + release() + if (this.historySpawnLocks.get(sessionId) === tail) { + this.historySpawnLocks.delete(sessionId) + } + } + } + + protected async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { + if (!this.respawnFn) { + return + } + + const health = await getMacDaemonSystemResolverHealth( + this.socketPath, + this.tokenPath, + this.protocolVersion + ) + if (health !== 'unhealthy') { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified' + : `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return + } + + // Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs. + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] macOS system resolver unavailable - respawning daemon', + 'unhealthy_resolver' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + /** Replace a stale packaged daemon only after its live sessions drain. */ + protected async replaceStaleBundleDaemonBeforeNewPty(): Promise { + if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) { + return + } + if (!this.staleBundleReplacementPromise) { + this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce( + this.runtimeDir, + this.packagedAppVersion + ).finally(() => { + this.staleBundleReplacementPromise = null + }) + } + await this.staleBundleReplacementPromise + } + + protected async replaceStaleBundleDaemonOnce( + runtimeDir: string, + packagedAppVersion: string + ): Promise { + const stale = await isDaemonStaleForCurrentBundle( + runtimeDir, + this.socketPath, + this.tokenPath, + packagedAppVersion, + this.protocolVersion + ) + if (!stale) { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified' + : `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return + } + + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] Packaged daemon is stale - respawning from the current app bundle', + 'stale_bundle' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + /** Replace a TCC-severed daemon only after its live sessions drain. */ + protected async replaceSeveredMacTccDaemonBeforeNewPty(): Promise { + // Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS. + if (!this.respawnFn || !this.runtimeDir) { + return + } + + const health = await getMacDaemonTccAttributionHealth( + this.runtimeDir, + this.socketPath, + this.tokenPath, + this.protocolVersion + ) + if (health !== 'severed') { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified' + : `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready` + ) + return + } + + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] macOS TCC attribution severed - respawning daemon under the current app binary', + 'severed_tcc_attribution' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + protected async getDaemonLiveSessionCount(): Promise { + try { + await this.client.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions.filter((session) => session.isAlive).length + } catch { + return null + } + } + + protected emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.backgroundStreamListeners]) { + listener(payload) + } + } + + protected async doRespawn( + message = '[daemon] Daemon died — respawning', + reason: DaemonRespawnReason = 'daemon_died' + ): Promise { + console.warn(message) + this.removeEventListener?.() + this.removeEventListener = null + this.client.disconnect() + const releaseAdoptionLease = await this.respawnFn!(reason) + if (this.respawnAdoptionClosed) { + // Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns. + releaseAdoptionLease?.() + throw new Error('Daemon adapter closed during respawn') + } + this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null + } + + protected releasePendingRespawnAdoptionLease(): void { + const release = this.pendingRespawnAdoptionRelease + this.pendingRespawnAdoptionRelease = null + release?.() + } +} diff --git a/src/main/daemon/daemon-pty-event-subscriptions.ts b/src/main/daemon/daemon-pty-event-subscriptions.ts new file mode 100644 index 00000000000..20929def1ba --- /dev/null +++ b/src/main/daemon/daemon-pty-event-subscriptions.ts @@ -0,0 +1,157 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import { DaemonPtySessionInventory } from './daemon-pty-session-inventory' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' +import type { PtyBackgroundStreamEvent } from '../providers/types' + +export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory { + onData( + callback: (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + }) => void + ): () => void { + this.dataListeners.push(callback) + return () => { + const idx = this.dataListeners.indexOf(callback) + if (idx !== -1) { + this.dataListeners.splice(idx, 1) + } + } + } + + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + this.backgroundStreamListeners.push(callback) + return () => { + const idx = this.backgroundStreamListeners.indexOf(callback) + if (idx !== -1) { + this.backgroundStreamListeners.splice(idx, 1) + } + } + } + + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { + return () => {} + } + + onExit( + callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + ): () => void { + this.exitListeners.push(callback) + return () => { + const idx = this.exitListeners.indexOf(callback) + if (idx !== -1) { + this.exitListeners.splice(idx, 1) + } + } + } + + onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { + this.writeUnavailableListeners.push(callback) + return () => { + const idx = this.writeUnavailableListeners.indexOf(callback) + if (idx !== -1) { + this.writeUnavailableListeners.splice(idx, 1) + } + } + } + + protected emitWriteUnavailable(id: string): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.writeUnavailableListeners]) { + listener({ id }) + } + } + + dispose(): void { + this.respawnAdoptionClosed = true + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.releasePendingRespawnAdoptionLease() + this.stopCheckpointTimer() + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.sessionsNeedingFullCheckpoint.clear() + this.sessionsNeedingLiveCheckpoint.clear() + this.sessionsNeedingContinuityCheckpoint.clear() + this.overlayDeadlineWarnedSessionIds.clear() + this.periodicDeadlineWarnedSessionIds.clear() + this.nonFinalAdmissionDeniedSessionIds.clear() + this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() + this.auditObservationListeners.length = 0 + this.identityChangeListeners.length = 0 + this.removeEventListener?.() + this.removeEventListener = null + // Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions + // cleanly ended so they don't trigger false cold restores. + if (this.historyManager) { + void this.historyManager + .dispose() + .catch((err) => console.warn('[history] dispose failed:', err)) + } + this.client.disconnect() + } + + async establishLifecycleLease(): Promise { + if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { + return + } + // Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit. + await this.client.ensureConnected() + this.recordAuthenticatedIdentity() + } + + // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, + // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. + async disconnectOnly(): Promise { + if (!this.disconnectOnlyPromise) { + this.respawnAdoptionClosed = true + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.releasePendingRespawnAdoptionLease() + this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns]) + } + await this.disconnectOnlyPromise + } + + protected async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { + // Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations. + await Promise.allSettled(keepHistoryShutdowns) + this.respawnAdoptionClosed = true + // Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon + // later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs. + await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), { + rescheduleDirty: false + }) + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() + // Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window. + for (const id of this.pausedProducerSessionIds) { + this.client.notify('resumePty', { sessionId: id }) + } + this.pausedProducerSessionIds.clear() + 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's empty; a shared budget keeps this 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 once its auth sockets close and it proves itself empty. + } + } + this.client.disconnect() + } +} diff --git a/src/main/daemon/daemon-pty-lifecycle-errors.ts b/src/main/daemon/daemon-pty-lifecycle-errors.ts new file mode 100644 index 00000000000..d27a362a8b9 --- /dev/null +++ b/src/main/daemon/daemon-pty-lifecycle-errors.ts @@ -0,0 +1,13 @@ +export class FinalCheckpointWaitExpiredError extends Error { + constructor(sessionId: string) { + super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`) + this.name = 'FinalCheckpointWaitExpiredError' + } +} + +export class TerminalKilledError extends Error { + constructor(sessionId: string) { + super(`Session "${sessionId}" was explicitly killed`) + this.name = 'TerminalKilledError' + } +} diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts new file mode 100644 index 00000000000..d1dd3ad4e36 --- /dev/null +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -0,0 +1,177 @@ +import { isShellProcess } from '../../shared/agent-detection' +import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots' +import { parsePtySessionId } from './pty-session-id' +import { + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + type ListSessionsResult, + type SessionInfo +} from './types' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' + +export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots { + // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. + protected hasChildProcessesFromForeground(foregroundProcess: string | null): boolean { + return foregroundProcess !== null && !isShellProcess(foregroundProcess) + } + + async hasChildProcesses(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return true + } + return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id)) + } + + async inspectProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return { foregroundProcess: null, hasChildProcesses: true, unavailable: true } + } + if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { + // Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the + // one call they do support instead of throwing, or completion detection stays dead until recreate. + // Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than + // reading as an idle foreground and dispatching a false completion. + const { foregroundProcess } = await this.client.request<{ + foregroundProcess: string | null + }>('getForegroundProcess', { sessionId: id }) + return { + foregroundProcess, + hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess) + } + } + return this.client.request<{ + foregroundProcess: string | null + hasChildProcesses: boolean + }>('inspectProcess', { sessionId: id }) + } + + async getForegroundProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return null + } + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'getForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } + } + + async confirmForegroundProcess(id: string): Promise { + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'confirmForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } + } + + async serialize(ids: string[]): Promise { + const sessions: Record = {} + for (const id of ids) { + sessions[id] = { initialCwd: this.initialCwds.get(id) } + } + return JSON.stringify(sessions) + } + + async revive(_state: string): Promise { + // Sessions already live in the daemon — no revival needed + } + + /** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId + * no longer exists, and caches alive session IDs. + * + * IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed + * worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live + * worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false + * orphans. No production caller yet; wire the alias in when it gains one. */ + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + + const alive: string[] = [] + const killed: string[] = [] + + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. + const { worktreeId } = parsePtySessionId(session.sessionId) + + if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { + try { + await this.client.request('kill', { sessionId: session.sessionId }) + } catch { + /* already dead */ + } + killed.push(session.sessionId) + } else { + alive.push(session.sessionId) + // Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data. + this.activeSessionIds.add(session.sessionId) + await this.reconcileLiveSessionHistory(session).catch((err) => + console.warn('[history] live-session reconciliation failed:', session.sessionId, err) + ) + } + } + + return { alive, killed } + } + + protected async reconcileLiveSessionHistory(session: SessionInfo): Promise { + const historyManager = this.historyManager + const historyReader = this.historyReader + if (!historyManager || !historyReader) { + return + } + await this.withHistorySpawnLock(session.sessionId, async () => { + if (historyManager.hasWriter(session.sessionId)) { + return + } + const probe = historyReader.probeRestorableHistory(session.sessionId) + if (probe.status === 'unreadable') { + return + } + if (probe.status === 'none') { + await historyManager.openSession(session.sessionId, { + cwd: session.cwd ?? '', + cols: session.cols, + rows: session.rows + }) + } else { + const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId) + try { + const detection = await historyReader.detectColdRestoreState(session.sessionId, { + wslDistro: session.wslDistro ?? undefined + }) + if ( + detection.status === 'unreadable' || + (detection.status === 'restored' && detection.hasUnreadableRecovery) + ) { + historyManager.suspendSession(session.sessionId, recoveryFreeze) + return + } + historyManager.reopenSession(session.sessionId, recoveryFreeze) + } finally { + historyManager.abandonRecoveryFreeze(recoveryFreeze) + } + } + if (historyManager.hasWriter(session.sessionId)) { + this.sessionsNeedingFullCheckpoint.add(session.sessionId) + this.sessionsNeedingContinuityCheckpoint.add(session.sessionId) + this.lastFullCheckpointAt.delete(session.sessionId) + this.markSessionDirty(session.sessionId) + } + }) + } +} diff --git a/src/main/daemon/daemon-pty-provider-sequence.ts b/src/main/daemon/daemon-pty-provider-sequence.ts new file mode 100644 index 00000000000..fbc38ab9221 --- /dev/null +++ b/src/main/daemon/daemon-pty-provider-sequence.ts @@ -0,0 +1,13 @@ +import type { CreateOrAttachResult } from './types' +import type { PtySpawnResult } from '../providers/types' + +export function providerSequenceFromCreateOrAttach( + result: CreateOrAttachResult +): PtySpawnResult['providerSequence'] { + if (result.isNew) { + return { value: 0, generation: 'reset' } + } + return typeof result.snapshot?.outputSequence === 'number' + ? { value: result.snapshot.outputSequence, generation: 'continued' } + : undefined +} diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts new file mode 100644 index 00000000000..5a1a7f60a90 --- /dev/null +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -0,0 +1,260 @@ +import { DaemonClient } from './client' +import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' +import type { + DaemonAuditContext, + DaemonAuditObservation, + DaemonAuditTrigger +} from './daemon-audit-classifier' +import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' +import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' +import { removeDaemonListener } from './daemon-listener-registry' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import { + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact, + supportsPtyStartupIngress, + type TakePendingOutputResult +} from './types' +import { ColdRestorePayloadCache } from './cold-restore-payload-cache' +import { + HistoryManager, + type HistoryCheckpointResult, + type HistoryRecoveryFreeze +} from './history-manager' +import { HistoryReader } from './history-reader' +import type { PtyBackgroundStreamEvent } from '../providers/types' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' + +export type PendingDaemonSpawnOperation = { + exitsBySessionId: Map + ignoredExitIncarnationIds: Set + ignoreNextExit: boolean +} + +export type HistoryRecoveryContext = { + freeze: HistoryRecoveryFreeze | null + unreadableSessionId: string | null + identityChanged: boolean +} + +export type SnapshotCheckpointResult = { + checkpoint: HistoryCheckpointResult + snapshot: NonNullable | null +} + +export type DaemonPtyAdapterOptions = { + socketPath: string + tokenPath: string + pidPath?: string + profileScope?: string + protocolVersion?: number + historyPath?: string + runtimeDir?: string + packagedAppVersion?: string | null + respawn?: (reason: DaemonRespawnReason) => Promise void)> +} + +export type DaemonRespawnReason = + | 'daemon_died' + | 'unhealthy_resolver' + | 'stale_bundle' + | 'severed_tcc_attribution' + +export type DaemonIdentityChangeEvent = { + previous: DaemonEndpointIdentity + current: DaemonEndpointIdentity +} + +export abstract class DaemonPtyRuntimeState { + readonly protocolVersion: number + protected socketPath: string + protected tokenPath: string + protected pidPath: string | null + protected pidRecord: ParsedDaemonPid | null + protected client: DaemonClient + protected auditContext: DaemonAuditContext + protected lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null + protected exactDaemonIncarnation: ExactDaemonIncarnation | null = null + protected lastAuditObservation: DaemonAuditObservation | null = null + protected readonly trackAuditEligibility = createDaemonAuditEligibilityTracker() + protected auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = [] + protected identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = [] + protected historyManager: HistoryManager | null + protected historyReader: HistoryReader | null + protected respawnFn: DaemonPtyAdapterOptions['respawn'] | null + protected runtimeDir: string | null + protected packagedAppVersion: string | null + protected pendingRespawnAdoptionRelease: (() => void) | null = null + protected respawnAdoptionClosed = false + protected respawnPromise: Promise | null = null + protected staleBundleReplacementPromise: Promise | null = null + protected writeRecoveryPromise: Promise | null = null + protected writeRecoveryAttempted = false + protected dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + }) => void)[] = [] + protected exitListeners: ((payload: { + id: string + code: number + incarnationId?: PtyIncarnationId + cause?: TerminalExitCause + }) => void)[] = [] + protected backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] + protected writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] + protected removeEventListener: (() => void) | null = null + protected initialCwds = new Map() + protected wslDistrosBySessionId = new Map() + protected killedSessionTombstones = new Map() + protected sleepRestoreSessionIds = new Set() + protected coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => { + this.sleepRestoreSessionIds.delete(sessionId) + }) + protected activeSessionIds = new Set() + protected getSizeUnsupported = false + protected sessionsAwaitingDaemonRecovery = new Set() + protected sessionIncarnations = new Map() + protected pendingSpawnOperationsBySessionId = new Map>() + protected pendingClaimSpawnOperations = new Set() + protected historySpawnLocks = new Map>() + protected dirtySessionVersions = new Map() + protected sessionsNeedingFullCheckpoint = new Set() + protected sessionsNeedingLiveCheckpoint = new Set() + protected sessionsNeedingContinuityCheckpoint = new Set() + protected checkpointTimer: ReturnType | null = null + protected checkpointInFlight: Promise | null = null + protected checkpointQueue = new CheckpointSessionQueue() + protected nonFinalCheckpointAdmissionSessionIds = new Set() + protected nonFinalAdmissionDeniedSessionIds = new Set() + protected nonFinalGlobalAdmissionWarningActive = false + protected overlayDeadlineWarnedSessionIds = new Set() + protected periodicDeadlineWarnedSessionIds = new Set() + protected keepHistoryShutdowns = new Set>() + protected disconnectOnlyPromise: Promise | null = null + protected supportsCheckpoints: boolean + protected supportsIncrementalCheckpoints: boolean + protected supportsProducerFlowControl: boolean + protected supportsAuthoritativeBufferSnapshots: boolean + protected supportsStartupIngress: boolean + protected pausedProducerSessionIds = new Set() + protected backgroundedSessionIds = new Set() + protected producerResumesOwedOnReconnect = new Set() + protected static CHECKPOINT_INTERVAL_MS = 5_000 + protected static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000 + protected static MAX_CONCURRENT_CHECKPOINTS = 4 + protected static FULL_CHECKPOINT_COOLDOWN_MS = 45_000 + protected lastFullCheckpointAt = new Map() + + protected abstract observeAuditFailure( + trigger: Exclude, + exactIncarnation?: ExactDaemonIncarnation | null, + additionalEvidenceSources?: readonly DaemonEvidenceSource[], + endpointGoneProof?: 'windows_named_pipe_missing' + ): void + + constructor(opts: DaemonPtyAdapterOptions) { + this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.socketPath = opts.socketPath + this.tokenPath = opts.tokenPath + this.pidPath = opts.pidPath ?? null + this.pidRecord = readDaemonPidRecord(this.pidPath) + this.auditContext = { + protocolGeneration: this.protocolVersion, + provider: 'local-daemon', + endpoint: opts.socketPath, + tokenPath: opts.tokenPath, + endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', + profileScope: opts.profileScope ?? '' + } + this.client = new DaemonClient({ + socketPath: opts.socketPath, + tokenPath: opts.tokenPath, + protocolVersion: opts.protocolVersion + }) + this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null + this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null + this.respawnFn = opts.respawn ?? null + this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null + this.packagedAppVersion = opts.packagedAppVersion ?? null + this.supportsCheckpoints = this.protocolVersion >= 4 + this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 + this.supportsProducerFlowControl = this.protocolVersion >= 19 + this.supportsAuthoritativeBufferSnapshots = + this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION + this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) + this.client.onDisconnected(() => { + if (!this.respawnAdoptionClosed) { + this.writeRecoveryAttempted = false + for (const id of this.activeSessionIds) { + this.sessionsAwaitingDaemonRecovery.add(id) + } + } + for (const id of this.pausedProducerSessionIds) { + this.producerResumesOwedOnReconnect.add(id) + } + this.pausedProducerSessionIds.clear() + this.observeAuditFailure('transport_closed') + }) + } + + supportsGitCredentialGuardHost(): boolean { + return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION + } + + canProvideAuthoritativeBufferSnapshot(_id: string): boolean { + return this.supportsAuthoritativeBufferSnapshots + } + + protected get canDelegateBackgroundToDaemon(): boolean { + return ( + this.supportsAuthoritativeBufferSnapshots && + supportsMode2031UnsubscribeFact(this.protocolVersion) + ) + } + + getHistoryManager(): HistoryManager | null { + return this.historyManager + } + + getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { + return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null + } + + getLastAuditObservation(): DaemonAuditObservation | null { + return this.lastAuditObservation + } + + onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void { + this.identityChangeListeners.push(listener) + return () => removeDaemonListener(this.identityChangeListeners, listener) + } + + onAuditEligibilityObservation( + listener: (observation: DaemonAuditObservation) => void + ): () => void { + this.auditObservationListeners.push(listener) + return () => removeDaemonListener(this.auditObservationListeners, listener) + } + + supportsAgentSessionClaims(): boolean { + return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + } + + providesAgentSessionOwnerListings(_ptyId: string): boolean { + return this.supportsAgentSessionClaims() + } + + supportsAgentSessionCreateOperations(): boolean { + return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION + } +} diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts new file mode 100644 index 00000000000..f213129a116 --- /dev/null +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -0,0 +1,357 @@ +import type { ColdRestorePayload } from './cold-restore-payload-cache' +import { isUnknownRequestTypeError } from './daemon-endpoint-errors' +import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version' +import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors' +import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn' +import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence' +import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' +import type { ColdRestoreInfo } from './history-reader' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' +import { SessionNotFoundError, type CreateOrAttachResult, type ListSessionsResult } from './types' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import type { PtySpawnResult } from '../providers/types' +import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 + +const MAX_TOMBSTONES = 1000 + +export abstract class DaemonPtySessionControl extends DaemonPtySessionSpawn { + async attach(id: string): Promise | void> { + await this.ensureConnected() + if (!this.canDelegateBackgroundToDaemon) { + this.setPtyBackgrounded(id, false) + } + + // Why size-first: attach must ride the session's own geometry — a fixed + // 80×24 here could resize a live agent's TUI — and a null size means the + // daemon cannot prove the session, so refuse rather than risk a create. + const size = await this.getAppliedSize(id) + if (!size) { + throw new SessionNotFoundError(id) + } + const result = await this.client.request('createOrAttach', { + sessionId: id, + cols: size.cols, + rows: size.rows, + attachOnly: true + }) + if (result.isNew) { + // Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn + // instead of publishing a fresh shell as an attach. + await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => { + // Why surface, not swallow: a failed retire leaves an untracked orphan shell. + console.warn('[daemon] attach-only retire of accidental legacy spawn failed', { + sessionId: id, + error + }) + }) + throw new SessionNotFoundError(id) + } + this.clearSessionAwaitingDaemonRecovery(id) + const providerSequence = providerSequenceFromCreateOrAttach(result) + return providerSequence ? { providerSequence } : undefined + } + + hasPty(id: string): boolean { + return this.activeSessionIds.has(id) + } + + async probePtyLiveness(id: string): Promise { + try { + if (!this.getSizeUnsupported && this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION) { + try { + const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( + 'getSize', + { sessionId: id }, + LIVENESS_PROBE_TIMEOUT_MS + ) + return result.size !== null + } catch (error) { + // Why the capability probe rather than the version alone: `getSize` shipped into an + // already-released protocol without a bump, so a daemon can report a version that + // implies support and still reject the request. Ask what it can do, not what its + // number implies — and remember the answer so later probes skip the dead round trip. + if (!isUnknownRequestTypeError(error)) { + throw error + } + this.getSizeUnsupported = true + } + } + // Why: a daemon without `getSize` would otherwise answer `null` forever, and one `null` + // makes the whole owner fan-out unprovable — a dead pane could then never be retired. + // `listSessions` is the same inventory legacy discovery routes by, and has existed since + // the first daemon protocol. Requested directly rather than through `listProcesses` so a + // liveness probe does not publish inventory audit observations as a side effect; both + // rethrow on failure, so either way a dead socket stays `null` instead of reading absent. + const { sessions } = await this.client.request( + 'listSessions', + undefined, + LIVENESS_PROBE_TIMEOUT_MS + ) + return sessions.some((session) => session.sessionId === id && session.isAlive) + } catch { + return null + } + } + + write(id: string, data: string): boolean { + const recoverable = this.prepareWrite(id) + return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable) + } + + async writeWithSettlement(id: string, data: string): Promise { + const recoverable = this.prepareWrite(id) + return this.finishWrite( + id, + await this.client.notifyWithSettlement('write', { sessionId: id, data }), + recoverable + ) + } + + protected prepareWrite(id: string): boolean { + this.markSessionDirty(id) + // Why recoverable and not just active: rejecting a write asks the pane to remount, + // which only helps if this endpoint can come back. A legacy adapter has no respawn, + // so its reattach fails and the pane rebuilds empty — losing scrollback the user + // could still read. Keep the pre-existing silent drop for those. + const recoverable = + this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn) + if ( + recoverable && + (this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected()) + ) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.reconnectAfterWriteFailure() + throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) + } + return recoverable + } + + protected finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean { + if (!delivered && recoverable) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.reconnectAfterWriteFailure() + throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) + } + return delivered + } + + resize(id: string, cols: number, rows: number): void { + this.markSessionDirty(id) + this.client.notify('resize', { sessionId: id, cols, rows }) + } + + pauseProducer(id: string): void { + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.add(id) + this.client.notify('pausePty', { sessionId: id }) + } + + resumeProducer(id: string): void { + this.producerResumesOwedOnReconnect.delete(id) + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.delete(id) + this.client.notify('resumePty', { sessionId: id }) + } + + // Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning. + setPtyBackgrounded(id: string, background: boolean): void { + if (!this.supportsProducerFlowControl) { + return + } + // Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream. + // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan + // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never + // retract it, so a TUI exiting while hidden would strand the subscription and the + // next theme flip would inject CSI 997 into its replacement shell. Declining to + // background keeps main's scanner — which emits both facts — authoritative. + const safeBackground = this.canDelegateBackgroundToDaemon && background + if (safeBackground) { + this.backgroundedSessionIds.add(id) + } else { + this.backgroundedSessionIds.delete(id) + } + this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) + } + + async shutdown( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + if (opts.keepHistory && this.disconnectOnlyPromise) { + throw new Error('Cannot keep history after daemon disconnect has started') + } + const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts)) + if (!opts.keepHistory) { + await shutdown + return + } + this.keepHistoryShutdowns.add(shutdown) + try { + await shutdown + } finally { + this.keepHistoryShutdowns.delete(shutdown) + } + } + + protected async shutdownWithHistoryLock( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + // Why: shutdown can be the first lazy-client operation after restart; connect + // before killing so a healthy daemon session is not orphaned (#7742). Connect, + // the final-checkpoint wait, and kill all share the caller's one absolute + // deadline, so neither a wedged handshake nor a stalled history write can burn + // the whole teardown budget before the kill even starts. Only the waits are + // bounded — the checkpoint itself stays deadline-free and lossless (STA-4228). + await this.ensureConnected(opts.deadlineMs) + // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. + // Force a final snapshot so wake can restore the pane users left. + if (opts.keepHistory) { + const committed = await this.runExclusiveCheckpoint( + async () => { + await this.checkpointSessions([id], { final: true, teardown: true }) + }, + { callerDeadlineMs: opts.deadlineMs } + ) + // Why throw instead of killing anyway: the snapshot the caller asked us to prove is still + // being written. Killing here would race the wake-time restore source to disk, so report the + // pty unverified and leave it alive — worktree sleep declines to commit it and retries. + if (!committed) { + throw new FinalCheckpointWaitExpiredError(id) + } + const wslDistro = this.wslDistrosBySessionId.get(id) + const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro }) + const detected = detection?.status === 'restored' ? detection.restoreInfo : null + const restoreInfo = detected + ? { + ...detected, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: detected.cwd, + requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), + wslDistro + }) ?? '' + } + : null + const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null + if (coldRestore) { + this.coldRestoreCache.set(id, coldRestore) + if (this.coldRestoreCache.has(id)) { + this.sleepRestoreSessionIds.add(id) + } + // Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority. + this.historyManager?.suspendSession(id) + } else if ( + detection?.status === 'unreadable' || + (detection?.status === 'restored' && detection.hasUnreadableRecovery) + ) { + this.historyManager?.suspendSession(id) + } + } + await this.client.request( + 'kill', + { sessionId: id, immediate: opts.immediate ?? false }, + remainingDaemonRequestTimeoutMs(opts.deadlineMs) + ) + this.activeSessionIds.delete(id) + this.clearSessionAwaitingDaemonRecovery(id) + this.dirtySessionVersions.delete(id) + if (!opts.keepHistory) { + this.coldRestoreCache.delete(id) + this.sleepRestoreSessionIds.delete(id) + } + // Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory). + this.sessionsNeedingFullCheckpoint.delete(id) + this.sessionsNeedingLiveCheckpoint.delete(id) + this.sessionsNeedingContinuityCheckpoint.delete(id) + this.overlayDeadlineWarnedSessionIds.delete(id) + this.periodicDeadlineWarnedSessionIds.delete(id) + this.nonFinalAdmissionDeniedSessionIds.delete(id) + this.lastFullCheckpointAt.delete(id) + this.stopCheckpointTimerIfIdle() + this.initialCwds.delete(id) + this.wslDistrosBySessionId.delete(id) + // Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory). + if (this.historyManager && !opts.keepHistory) { + await this.historyManager + .removeSession(id) + .catch((err) => console.warn('[history] removeSession failed:', id, err)) + } + + // Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory. + if (!opts.keepHistory) { + this.killedSessionTombstones.delete(id) + this.killedSessionTombstones.set(id, Date.now()) + if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { + const oldest = this.killedSessionTombstones.keys().next().value + if (oldest) { + this.killedSessionTombstones.delete(oldest) + } + } + } + } + + ackColdRestore(sessionId: string): void { + this.coldRestoreCache.delete(sessionId) + this.sleepRestoreSessionIds.delete(sessionId) + } + + clearTombstone(sessionId: string): void { + this.killedSessionTombstones.delete(sessionId) + } + + protected buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { + // Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake. + const scrollback = restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + if (!scrollback) { + return null + } + return { + scrollback, + cwd: restoreInfo.cwd, + cols: restoreInfo.cols, + rows: restoreInfo.rows, + oscLinks: restoreInfo.oscLinks, + ...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {}) + } + } + + async sendSignal(id: string, signal: string): Promise { + await this.client.request('signal', { sessionId: id, signal }) + } + + async getCwd(id: string): Promise { + try { + const result = await this.client.request<{ cwd: string | null }>('getCwd', { + sessionId: id + }) + return result.cwd ?? '' + } catch { + return '' + } + } + + async getInitialCwd(id: string): Promise { + return this.initialCwds.get(id) ?? '' + } + + // Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert. + async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { + try { + const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( + 'getSize', + { sessionId: id } + ) + return result.size ?? null + } catch { + return null + } + } +} diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts new file mode 100644 index 00000000000..7a02a7e1e3d --- /dev/null +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -0,0 +1,174 @@ +import { basename } from 'node:path' +import { existsSync } from 'node:fs' +import { + isAgentSessionOwnerBinding, + type AgentSessionOwnerBinding +} from '../../shared/agent-session-host-authority' +import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' +import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot' +import { recordAuthenticatedInventory } from './daemon-audit-classifier' +import { isMissingWindowsNamedPipeError } from './daemon-endpoint-errors' +import { DaemonPtyProcessInspection } from './daemon-pty-process-inspection' +import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' +import { parsePtySessionId } from './pty-session-id' +import type { ListSessionsResult, SessionInfo } from './types' +import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' +import type { PtyProcessInfo } from '../providers/types' + +export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection { + async listProcesses(opts?: { deadlineMs?: number }): Promise { + // Why: snapshotted before the request so ids spawned mid-flight can never + // be reconciled away below. + const preRequestActiveIds = new Set(this.activeSessionIds) + try { + // Why: connect + listSessions share the caller's one absolute deadline so a + // wedged handshake cannot burn the whole teardown budget before the list issues. + await this.ensureConnected(opts?.deadlineMs) + const result = await this.client.request( + 'listSessions', + undefined, + remainingDaemonRequestTimeoutMs(opts?.deadlineMs) + ) + const admission = new PtyProcessListAdmission() + const processes: PtyProcessInfo[] = [] + const aliveSessionIds = new Set() + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + aliveSessionIds.add(session.sessionId) + const { worktreeId } = parsePtySessionId(session.sessionId) + processes.push( + admission.admit({ + id: session.sessionId, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. + cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', + title: 'shell', + ...(worktreeId ? { worktreeId } : {}), + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + }) + ) + } + // Why: hasPty reads activeSessionIds, and an exit missed while the socket + // was disconnected otherwise survives an authoritative inventory forever — + // defeating every absence proof built on the cache. + for (const id of preRequestActiveIds) { + if (!aliveSessionIds.has(id)) { + this.activeSessionIds.delete(id) + } + } + this.publishAuditObservation( + recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation) + ) + return processes + } catch (error) { + const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing() + const missingNamedPipe = isMissingWindowsNamedPipeError(error) + this.observeAuditFailure( + missingAuthenticatedToken + ? 'token_missing_after_authenticated_disconnect' + : 'inventory_failed', + this.exactDaemonIncarnation, + [ + ...(missingAuthenticatedToken ? (['token_file'] as const) : []), + ...(missingNamedPipe ? (['windows_named_pipe'] as const) : []) + ], + missingNamedPipe ? 'windows_named_pipe_missing' : undefined + ) + throw error + } + } + + protected validatedAgentSessionOwners( + owners: unknown + ): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record { + if (owners === undefined) { + return {} + } + if ( + !Array.isArray(owners) || + owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES || + !owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live') + ) { + throw new Error('agent_session_ownership_unknown') + } + return owners.length > 0 + ? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) } + : {} + } + + // Why: the Manage Sessions panel needs the full SessionInfo (pid, state, + // createdAt) per session for display; listProcesses drops that detail for + // the IPtyProvider contract. Keep both in parallel rather than widening + // the provider surface. + async listSessions(): Promise { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions + .filter((s) => s.isAlive) + .map((session) => ({ + ...session, + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + })) + } + + getActiveSessionIds(): string[] { + return [...this.activeSessionIds] + } + + // Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit + // for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever. + fanoutSyntheticExits(code: number): void { + const ids = [...this.activeSessionIds] + this.activeSessionIds.clear() + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.sessionsNeedingFullCheckpoint.clear() + this.sessionsNeedingLiveCheckpoint.clear() + this.sessionsNeedingContinuityCheckpoint.clear() + this.overlayDeadlineWarnedSessionIds.clear() + this.periodicDeadlineWarnedSessionIds.clear() + this.nonFinalAdmissionDeniedSessionIds.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() + this.stopCheckpointTimer() + for (const id of ids) { + this.coldRestoreCache.delete(id) + // Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics. + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.exitListeners]) { + listener({ + id, + code, + ...(this.sessionIncarnations.get(id) + ? { incarnationId: this.sessionIncarnations.get(id) } + : {}) + }) + } + this.sessionIncarnations.delete(id) + } + } + + async getDefaultShell(): Promise { + if (process.platform === 'win32') { + return process.env.COMSPEC || 'powershell.exe' + } + return process.env.SHELL || '/bin/zsh' + } + + async getProfiles(): Promise<{ name: string; path: string }[]> { + if (process.platform === 'win32') { + return [ + { name: 'PowerShell', path: 'powershell.exe' }, + { name: 'Command Prompt', path: 'cmd.exe' } + ] + } + const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] + return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) + } +} diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts new file mode 100644 index 00000000000..d277a52866f --- /dev/null +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -0,0 +1,281 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import type { + HistoryRecoveryContext, + PendingDaemonSpawnOperation +} from './daemon-pty-runtime-state' +import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +import { TerminalKilledError } from './daemon-pty-lifecycle-errors' +import { DaemonPtySpawnResult } from './daemon-pty-spawn-result' +import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request' +import type { ColdRestoreInfo } from './history-reader' +import { mintPtySessionId } from './pty-session-id' +import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier' +import { supportsPtyStartupBarrier } from './shell-ready' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' +import { resolveWslSessionContext } from './wsl-session-context' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import { resolveUnixShellPath } from '../providers/local-pty-utils' +import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' +import { addWslEnvKeys } from '../wsl-env' + +export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { + async spawn(opts: PtySpawnOptions): Promise { + const spawnOpts = this.withHistoryIsolation(opts) + const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) + const operation = { + exitsBySessionId: new Map(), + ignoredExitIncarnationIds: new Set(), + ignoreNextExit: false + } + const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set() + operations.add(operation) + this.pendingSpawnOperationsBySessionId.set(sessionId, operations) + if (opts.agentSessionEnsure) { + this.pendingClaimSpawnOperations.add(operation) + } + const historyRecovery: HistoryRecoveryContext = { + freeze: null, + unreadableSessionId: null, + identityChanged: false + } + try { + return await this.withHistorySpawnLock(sessionId, () => + this.withDaemonRetry(() => + this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) + ) + ) + } finally { + if (historyRecovery.freeze) { + this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) + } + this.pendingClaimSpawnOperations.delete(operation) + operations.delete(operation) + if (operations.size === 0) { + this.pendingSpawnOperationsBySessionId.delete(sessionId) + } + } + } + + protected withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { + const wslContext = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId: opts.sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + }) + if ( + opts.attachOnly === true || + (opts.sessionId !== undefined && opts.isNewSession !== true) || + !opts.worktreeId || + opts.historyIsolationEnabled !== true || + (process.platform === 'win32' && !wslContext) + ) { + return opts + } + const env = { ...opts.env } + const preferredShell = wslContext + ? 'bash' + : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' + const shellPath = resolveUnixShellPath(preferredShell) + const historyArgs = [ + env, + opts.worktreeId, + shellPath, + opts.cwd ?? resolveSafePtyDefaultCwd() + ] as const + const result = wslContext + ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) + : injectHistoryEnv(...historyArgs) + if (wslContext) { + injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) + addWslEnvKeys(env, ['HISTFILE', 'fish_history']) + } + logHistoryInjection(opts.worktreeId, result) + return { ...opts, env } + } + + protected async doSpawn( + opts: PtySpawnOptions, + operation: PendingDaemonSpawnOperation, + historyRecovery: HistoryRecoveryContext + ): Promise { + if ( + opts.agentSessionEnsure && + this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + ) { + throw new Error('agent_session_claim_unavailable') + } + const requestedSessionId = opts.sessionId! + // Why: v30 daemons survive upgrades; reject their accidental create result before publication. + const attachOnly = opts.attachOnly === true + const emulateLegacyAttachOnly = + attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION + let sessionId = requestedSessionId + let wslDistro = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + })?.distro + let activeSpawnContext: DaemonPtySpawnContext | null = null + const freezeHistory = async (): Promise => { + if (!this.historyManager) { + return + } + const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId + if (historyRecovery.freeze?.sessionId === recoverySessionId) { + return + } + if (historyRecovery.freeze) { + this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze) + } + historyRecovery.freeze = await this.historyManager.freezeForRecovery(recoverySessionId) + historyRecovery.unreadableSessionId = null + } + const detectColdRestore = async (options?: { + ignoreCleanEnd?: boolean + }): Promise => { + if (!this.historyReader) { + return null + } + await freezeHistory() + const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId + const recoveryWslDistro = activeSpawnContext?.wslDistro ?? wslDistro + const detection = await this.historyReader.detectColdRestoreState(recoverySessionId, { + ...options, + wslDistro: recoveryWslDistro + }) + if (detection.status === 'unreadable') { + historyRecovery.unreadableSessionId = detection.sessionId + return null + } + const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null + if (detection.status === 'restored' && detection.hasUnreadableRecovery) { + historyRecovery.unreadableSessionId = detection.sessionId + } + if (!restoreInfo) { + return null + } + return { + ...restoreInfo, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: restoreInfo.cwd, + requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), + wslDistro: recoveryWslDistro + }) ?? '' + } + } + + if (this.killedSessionTombstones.has(sessionId)) { + throw new TerminalKilledError(sessionId) + } + + if (opts.isNewSession) { + await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() + await this.replaceStaleBundleDaemonBeforeNewPty() + await this.replaceSeveredMacTccDaemonBeforeNewPty() + } + + await this.ensureConnected() + // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from + // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop + // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. + if (!this.canDelegateBackgroundToDaemon) { + this.setPtyBackgrounded(sessionId, false) + } + + // Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request. + // Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted. + let restoreInfo: ColdRestoreInfo | null = null + let restoreSkippedForLiveSession = false + const historyProbe = opts.attachOnly + ? undefined + : this.historyReader?.probeRestorableHistory(sessionId) + if (historyProbe && historyProbe.status !== 'none') { + if ((await this.getAppliedSize(sessionId)) !== null) { + restoreSkippedForLiveSession = true + if (this.historyManager && !this.historyManager.hasWriter(sessionId)) { + await detectColdRestore() + restoreInfo = null + } + } else { + restoreInfo = await detectColdRestore() + } + } + let effectiveCwd = restoreInfo?.cwd ?? opts.cwd + let effectiveCols = restoreInfo?.cols ?? opts.cols + let effectiveRows = restoreInfo?.rows ?? opts.rows + + const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false + const isCodexStartupCommand = + recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' + const shouldWaitForShellReady = + isCodexStartupCommand && + shouldUseShellReadyStartupDelivery({ + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery + }) + const shellReadyTimeoutMs = + shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady + ? CODEX_SHELL_READY_TIMEOUT_MS + : undefined + + const context: DaemonPtySpawnContext = { + opts, + operation, + historyRecovery, + requestedSessionId, + attachOnly, + emulateLegacyAttachOnly, + sessionId, + wslDistro, + restoreInfo, + restoreSkippedForLiveSession, + effectiveCwd, + effectiveCols, + effectiveRows, + shellReadySupported, + shellReadyTimeoutMs, + historySeedSegments: restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null, + detectColdRestore + } + activeSpawnContext = context + const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + return this.finishSpawn(context, result) + } + + protected resultForExitBeforeSpawnReply( + sessionId: string, + result: CreateOrAttachResult, + operation: PendingDaemonSpawnOperation + ): PtySpawnResult | null { + const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some( + (exit) => + !(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) && + (!exit.incarnationId || + !result.incarnationId || + exit.incarnationId === result.incarnationId) + ) + if (!matchingExit) { + return null + } + // Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state. + const exitedResult: PtySpawnResult = { + id: sessionId, + exitedBeforeSpawnReply: true, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + return exitedResult + } + + didExitBeforeSpawnReply(result: PtySpawnResult): boolean { + return result.exitedBeforeSpawnReply === true + } +} diff --git a/src/main/daemon/daemon-pty-spawn-preparations.ts b/src/main/daemon/daemon-pty-spawn-preparations.ts new file mode 100644 index 00000000000..633f0d1c7f5 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-preparations.ts @@ -0,0 +1,118 @@ +import { TerminalAttachCanceledError } from './types' + +export type PendingPtySpawnPreparation = { + canceled: boolean + // Why: `canceled` is only polled between spawn steps; the signal is what + // actually interrupts an in-progress cwd probe on a dead share. + readonly controller: AbortController + cancelTimer?: ReturnType + // Why: preparations are keyed by sessionId, but a control-socket close must + // cancel only the disconnecting client's preps, not another client's (F4). + clientId: string + requestId: string +} + +export class DaemonPtySpawnPreparations { + private readonly pending = new Map>() + private readonly cancellationByPreparation = new WeakMap< + PendingPtySpawnPreparation, + { promise: Promise; resolve: () => void } + >() + + constructor(private readonly preparePtySpawn: () => Promise) {} + + register( + sessionId: string, + clientId: string, + requestId: string, + cancelAfterMs: unknown + ): PendingPtySpawnPreparation { + const preparation: PendingPtySpawnPreparation = { + canceled: false, + controller: new AbortController(), + clientId, + requestId + } + this.cancellationByPreparation.set(preparation, Promise.withResolvers()) + if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { + preparation.cancelTimer = setTimeout( + () => this.cancelPreparation(preparation), + Math.min(Number(cancelAfterMs), 300_000) + ) + preparation.cancelTimer.unref() + } + const pendingForSession = this.pending.get(sessionId) ?? new Set() + pendingForSession.add(preparation) + this.pending.set(sessionId, pendingForSession) + return preparation + } + + async prepareUnlessCanceled( + sessionId: string, + preparation: PendingPtySpawnPreparation + ): Promise { + // Race cancellation against preflight so shutdown/disconnect can return the + // protocol cancellation error before tearing down the client transport. + const cancellation = this.cancellationByPreparation.get(preparation) + const preparationTask = cancellation + ? Promise.race([this.preparePtySpawn(), cancellation.promise]) + : this.preparePtySpawn() + await preparationTask + if (preparation.canceled) { + throw new TerminalAttachCanceledError(sessionId) + } + } + + finish(sessionId: string, preparation: PendingPtySpawnPreparation): void { + if (preparation.cancelTimer) { + clearTimeout(preparation.cancelTimer) + } + this.cancellationByPreparation.delete(preparation) + const pendingForSession = this.pending.get(sessionId) + pendingForSession?.delete(preparation) + if (pendingForSession?.size === 0) { + this.pending.delete(sessionId) + } + } + + cancel(sessionId: string, request?: { clientId: string; requestId?: string }): boolean { + const pendingForSession = this.pending.get(sessionId) + if (!pendingForSession) { + return false + } + let canceled = false + for (const preparation of pendingForSession) { + if ( + request && + (preparation.clientId !== request.clientId || + (request.requestId !== undefined && preparation.requestId !== request.requestId)) + ) { + continue + } + this.cancelPreparation(preparation) + canceled = true + } + return canceled + } + + cancelForClient(clientId: string): void { + for (const pendingForSession of this.pending.values()) { + for (const preparation of pendingForSession) { + if (preparation.clientId === clientId) { + this.cancelPreparation(preparation) + } + } + } + } + private cancelPreparation(preparation: PendingPtySpawnPreparation): void { + preparation.canceled = true + preparation.controller.abort() + this.cancellationByPreparation.get(preparation)?.resolve() + } + + cancelAll(): void { + for (const sessionId of this.pending.keys()) { + this.cancel(sessionId) + } + } +} diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts new file mode 100644 index 00000000000..8078a9068f3 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -0,0 +1,202 @@ +import type { ColdRestorePayload } from './cold-restore-payload-cache' +import type { DaemonAuditObservation } from './daemon-audit-classifier' +import { + DaemonPtyRuntimeState, + type HistoryRecoveryContext, + type PendingDaemonSpawnOperation, + type SnapshotCheckpointResult +} from './daemon-pty-runtime-state' +import { isDaemonGoneError } from './daemon-endpoint-errors' +import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' +import type { ColdRestoreInfo } from './history-reader' +import { NdjsonLineTooLongError } from './ndjson' +import { + iterateTerminalHistorySeedChunks, + measureTerminalHistorySeed, + TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS +} from './terminal-history-seed-chunks' +import type { CreateOrAttachResult, GetSnapshotResult } from './types' +import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' + +export type DaemonPtySpawnContext = { + opts: PtySpawnOptions + operation: PendingDaemonSpawnOperation + historyRecovery: HistoryRecoveryContext + requestedSessionId: string + attachOnly: boolean + emulateLegacyAttachOnly: boolean + sessionId: string + wslDistro: string | undefined + restoreInfo: ColdRestoreInfo | null + restoreSkippedForLiveSession: boolean + effectiveCwd: string | undefined + effectiveCols: number + effectiveRows: number + shellReadySupported: boolean + shellReadyTimeoutMs: number | undefined + historySeedSegments: readonly string[] | null + detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise +} + +export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { + protected abstract takeSnapshotAndCheckpoint( + sessionId: string, + opts: { + teardown: boolean + forceLiveSnapshot?: boolean + requireContinuityProof?: boolean + } + ): Promise + protected abstract markSessionDirty(sessionId: string): void + protected abstract writeSessionCheckpoint( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> + protected abstract releasePendingRespawnAdoptionLease(): void + protected abstract setupEventRouting(): void + protected abstract scheduleCheckpointTimer(): void + protected abstract stopCheckpointTimer(): void + protected abstract stopCheckpointTimerIfIdle(): void + protected abstract recordAuthenticatedIdentity(): void + protected abstract runExclusiveCheckpoint( + operation: () => Promise, + options?: { rescheduleDirty?: boolean; callerDeadlineMs?: number } + ): Promise + protected abstract checkpointAllSessions(): Promise + protected abstract ensureConnected(deadlineMs?: number): Promise + protected abstract withHistorySpawnLock( + sessionId: string, + operation: () => Promise + ): Promise + protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void + protected abstract reconnectAfterWriteFailure(): void + protected abstract checkpointSessions( + sessionIds: Iterable, + opts?: { final?: boolean; teardown?: boolean } + ): Promise> + protected abstract publishAuditObservation(observation: DaemonAuditObservation): void + protected abstract isRetiredEndpointTokenMissing(): boolean + protected abstract withDaemonRetry(fn: () => Promise): Promise + protected abstract replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise + protected abstract replaceStaleBundleDaemonBeforeNewPty(): Promise + protected abstract replaceSeveredMacTccDaemonBeforeNewPty(): Promise + abstract setPtyBackgrounded(id: string, background: boolean): void + abstract getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> + protected abstract resultForExitBeforeSpawnReply( + sessionId: string, + result: CreateOrAttachResult, + operation: PendingDaemonSpawnOperation + ): PtySpawnResult | null + protected abstract buildColdRestorePayload( + restoreInfo: ColdRestoreInfo + ): ColdRestorePayload | null + protected abstract overlayDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> + + protected async createOrAttachSpawn( + context: DaemonPtySpawnContext, + historySeedSegments: readonly string[] | null + ): Promise { + const requestCreateOrAttach = ( + historySeed: string | undefined, + historySeedTransferId: string | undefined + ) => { + const { opts } = context + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + const payload = { + sessionId: context.sessionId, + cols: context.effectiveCols, + rows: context.effectiveRows, + cwd: context.attachOnly ? undefined : context.effectiveCwd, + env: context.attachOnly ? undefined : opts.env, + envToDelete: context.attachOnly ? undefined : opts.envToDelete, + command: context.attachOnly ? undefined : opts.command, + startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, + launchAgent: context.attachOnly ? undefined : opts.launchAgent, + ...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}), + shellOverride: context.attachOnly ? undefined : opts.shellOverride, + terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: context.attachOnly + ? undefined + : opts.terminalWindowsPowerShellImplementation, + shellReadySupported: context.attachOnly ? false : context.shellReadySupported, + ...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: context.shellReadyTimeoutMs } + : {}), + ...(historySeed ? { historySeed } : {}), + ...(historySeedTransferId ? { historySeedTransferId } : {}), + ...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress + ? { startupIngress: opts.startupIngress } + : {}), + ...(!context.attachOnly && opts.agentSessionEnsure + ? { agentSessionEnsure: opts.agentSessionEnsure } + : {}) + } + return opts.signal + ? this.client.request( + 'createOrAttach', + payload, + undefined, + opts.signal + ) + : this.client.request('createOrAttach', payload) + } + + let historySeedUnavailable = false + const deliverSeedAndCreate = async (): Promise => { + if (!historySeedSegments || historySeedSegments.length === 0) { + return requestCreateOrAttach(undefined, undefined) + } + const metrics = measureTerminalHistorySeed(historySeedSegments) + if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) { + try { + return await requestCreateOrAttach(historySeedSegments.join(''), undefined) + } catch (error) { + if (!(error instanceof NdjsonLineTooLongError)) { + throw error + } + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + } + if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) { + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + + let transferId: string | undefined + try { + const started = await this.client.request<{ transferId: string }>( + 'startHistorySeedTransfer', + metrics + ) + transferId = started.transferId + let index = 0 + for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) { + await this.client.request('appendHistorySeedTransfer', { transferId, index, data }) + index += 1 + } + await this.client.request('finishHistorySeedTransfer', { transferId }) + } catch (error) { + if (transferId) { + await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {}) + } + if (isDaemonGoneError(error)) { + throw error + } + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + return requestCreateOrAttach(undefined, transferId) + } + const result = await deliverSeedAndCreate() + return historySeedUnavailable && result.historySeeded === undefined + ? { ...result, historySeeded: false } + : result + } +} diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts new file mode 100644 index 00000000000..c40e8b6a9bd --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -0,0 +1,326 @@ +import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request' +import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence' +import { takeHistoryRecoveryFreeze } from './daemon-history-recovery-freeze' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { SessionNotFoundError, type CreateOrAttachResult } from './types' +import type { PtySpawnResult } from '../providers/types' + +export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { + protected async finishSpawn( + context: DaemonPtySpawnContext, + initialResult: CreateOrAttachResult + ): Promise { + const { + opts, + operation, + historyRecovery, + requestedSessionId, + emulateLegacyAttachOnly, + restoreSkippedForLiveSession, + detectColdRestore + } = context + let { sessionId, wslDistro, restoreInfo, effectiveCwd, effectiveCols, effectiveRows } = context + const createOrAttach = (historySeedSegments: readonly string[] | null) => { + Object.assign(context, { + sessionId, + effectiveCwd, + effectiveCols, + effectiveRows + }) + return this.createOrAttachSpawn(context, historySeedSegments) + } + let result = initialResult + let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { + const requestedSessionId = sessionId + if ( + opts.agentSessionEnsure && + !isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure) + ) { + // Why: a claim-incapable owner may already have spawned before returning + // a malformed response; retire only this requested session before failing closed. + await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {}) + throw new Error('agent_session_claim_unavailable') + } + sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId + context.sessionId = sessionId + if (requestedSessionId === sessionId) { + return + } + if (historyRecovery.freeze) { + this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) + historyRecovery.freeze = null + } + historyRecovery.unreadableSessionId = null + historyRecovery.identityChanged = true + restoreInfo = null + historySeedSegments = null + } + if (emulateLegacyAttachOnly && result.isNew) { + operation.ignoreNextExit = true + await this.client.request('kill', { sessionId: requestedSessionId, immediate: true }) + throw new SessionNotFoundError(requestedSessionId) + } + await adoptSpawnResultSession(result) + // Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner. + this.clearSessionAwaitingDaemonRecovery(requestedSessionId) + this.clearSessionAwaitingDaemonRecovery(sessionId) + const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (exitedResult) { + return exitedResult + } + if (result.incarnationId) { + this.sessionIncarnations.set(sessionId, result.incarnationId) + } + const claimResult = (): Pick | Record => + result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} + const incarnationResult = (): Pick | Record => + result.incarnationId ? { incarnationId: result.incarnationId } : {} + let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. + wslDistro = providerWslDistro ?? undefined + context.wslDistro = wslDistro + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (providerWslDistro === null || result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } + const launchIdentity = (): { launchAgent?: NonNullable } => + result.launchAgent ? { launchAgent: result.launchAgent } : {} + + if (effectiveCwd) { + this.initialCwds.set(sessionId, effectiveCwd) + } + + // Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor. + let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + + // Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data. + const cachedRestore = this.coldRestoreCache.get(sessionId) + if (cachedRestore) { + // Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal. + this.activeSessionIds.add(sessionId) + if (this.historyManager && !historyRecovery.identityChanged) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + if (historyRecovery.unreadableSessionId === sessionId) { + this.historyManager.suspendSession(sessionId, recoveryFreeze) + } else { + this.historyManager.reopenSession(sessionId, recoveryFreeze) + } + } + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + coldRestore: cachedRestore, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + } + + // Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path. + // Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it. + if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) { + restoreInfo = await detectColdRestore({ ignoreCleanEnd: true }) + historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) { + // Why: the aliveness probe raced with session death, so the first + // create lacked recovery bytes. Replace it before exposing the PTY. + if (result.incarnationId) { + operation.ignoredExitIncarnationIds.add(result.incarnationId) + } + operation.ignoreNextExit = true + await this.client.request('kill', { sessionId, immediate: true }) + effectiveCwd = restoreInfo.cwd + effectiveCols = restoreInfo.cols + effectiveRows = restoreInfo.rows + result = await createOrAttach(historySeedSegments) + await adoptSpawnResultSession(result) + const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (exitedRetryResult) { + return exitedRetryResult + } + if (result.incarnationId) { + this.sessionIncarnations.set(sessionId, result.incarnationId) + } + providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + wslDistro = providerWslDistro ?? undefined + context.wslDistro = wslDistro + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (providerWslDistro === null || result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } + pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + this.initialCwds.set(sessionId, effectiveCwd) + } + } else if ( + !historyRecovery.identityChanged && + !result.isNew && + result.historySeeded === false + ) { + restoreInfo = await detectColdRestore() + historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + } + + const wasAlreadyManaged = this.activeSessionIds.has(sessionId) + this.activeSessionIds.add(sessionId) + const providerSequence = providerSequenceFromCreateOrAttach(result) + + // Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback. + if (restoreInfo && (result.isNew || result.historySeeded === false)) { + const coldRestore = this.buildColdRestorePayload(restoreInfo) + const canReanchorHistory = + !historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true + // Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick. + if (this.historyManager && !historyRecovery.identityChanged) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + if (historyRecovery.unreadableSessionId === sessionId) { + await this.historyManager.openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + ...(recoveryFreeze ? { recoveryFreeze } : {}), + quarantineUnreadableRecovery: true + }) + if (this.historyManager.hasWriter(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingLiveCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } + } else if (canReanchorHistory) { + this.historyManager.registerWriter(sessionId, recoveryFreeze) + this.sessionsNeedingFullCheckpoint.add(sessionId) + // Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor. + this.lastFullCheckpointAt.delete(sessionId) + } else { + // Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent. + this.historyManager.suspendSession(sessionId, recoveryFreeze) + } + } + if (coldRestore) { + this.coldRestoreCache.set(sessionId, coldRestore) + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + coldRestore, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + } + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}) + } + } + + if (this.historyManager && !historyRecovery.identityChanged && result.isNew) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + await this.historyManager.openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + ...(recoveryFreeze ? { recoveryFreeze } : {}), + ...(historyRecovery.unreadableSessionId === sessionId + ? { quarantineUnreadableRecovery: true } + : {}) + }) + } else if ( + this.historyManager && + !historyRecovery.identityChanged && + (result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId) + ) { + // Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart. + this.historyManager.suspendSession( + sessionId, + takeHistoryRecoveryFreeze(historyRecovery, sessionId) + ) + } else if (this.historyManager && !historyRecovery.identityChanged) { + // Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint. + this.historyManager.registerWriter( + sessionId, + takeHistoryRecoveryFreeze(historyRecovery, sessionId) + ) + if (!wasAlreadyManaged) { + // Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity. + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingContinuityCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } + } + + const isReattach = !result.isNew + if (!isReattach || !result.snapshot) { + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}), + ...(isReattach ? { isReattach: true } : {}) + } + } + + const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot) + const reattachProviderSequence = + typeof reattachSnapshot.outputSequence === 'number' + ? { value: reattachSnapshot.outputSequence, generation: 'continued' as const } + : providerSequence + const isAltScreen = reattachSnapshot.modes.alternateScreen + const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences + const snapshotFrame = reattachSnapshot.snapshotAnsi + const snapshotPayload = snapshotPrefix + snapshotFrame + // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). + // Why known `0` is no longer dropped: the pane tracker must be able to tell + // "the app negotiated nothing" from "this reattach proved nothing". + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( + reattachSnapshot.modes.kittyKeyboardFlags + ) + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + snapshot: snapshotPayload, + snapshotCols: reattachSnapshot.cols, + snapshotRows: reattachSnapshot.rows, + // Why only for an alt frame: normal history remains safe to replay at its capture grid. + ...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi + ? { + snapshotPrefixAnsi: snapshotPrefix, + snapshotFrameAnsi: snapshotFrame, + snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi + } + : {}), + ...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}), + ...(kittyKeyboardFlags !== undefined + ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } + : {}), + isReattach: true, + isAlternateScreen: isAltScreen, + // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. + ...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}), + // Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329). + ...(reattachSnapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi } + : {}) + } + } +} diff --git a/src/main/daemon/daemon-request-deadline.ts b/src/main/daemon/daemon-request-deadline.ts new file mode 100644 index 00000000000..d98c1c5f7dd --- /dev/null +++ b/src/main/daemon/daemon-request-deadline.ts @@ -0,0 +1,25 @@ +export function remainingDaemonRequestTimeoutMs( + deadlineMs: number | undefined +): number | undefined { + return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now()) +} + +// The caller's wait is bounded without cancelling durable work, which keeps running and committing. +export async function awaitDaemonWorkWithinCallerDeadline( + work: Promise, + deadlineMs: number +): Promise { + void work.catch(() => {}) + let timer: ReturnType | undefined + try { + return await Promise.race([ + work.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now())) + timer.unref?.() + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts new file mode 100644 index 00000000000..826c73b4370 --- /dev/null +++ b/src/main/daemon/daemon-request-router.ts @@ -0,0 +1,282 @@ +import { performance } from 'node:perf_hooks' +import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' +import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections' +import type { DaemonFileLog } from './daemon-file-log' +import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations' +import type { DaemonServerLifecycle } from './daemon-server-lifecycle' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import type { DaemonSessionBackgroundRouting } from './daemon-session-background-routing' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { DaemonTerminalAdmission } from './daemon-terminal-admission' +import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import type { TerminalHost } from './terminal-host' +import { SessionNotFoundError, type DaemonRequest } from './types' + +type DaemonRequestRouterOptions = { + host: TerminalHost + connections: DaemonClientConnections + lifecycle: DaemonServerLifecycle + admission: DaemonTerminalAdmission + preparations: DaemonPtySpawnPreparations + attachments: DaemonSessionAttachments + historySeedTransfers: TerminalHistorySeedTransferRegistry + sessionBackgroundRouting: DaemonSessionBackgroundRouting + streamDataBatcher: DaemonStreamDataBatcher + ptySpawnHealthCheck: () => Promise + log: DaemonFileLog +} + +export class DaemonRequestRouter { + constructor(private readonly options: DaemonRequestRouterOptions) {} + + async route(clientId: string, request: DaemonRequest): Promise { + const client = this.options.connections.get(clientId) + switch (request.type) { + case 'startHistorySeedTransfer': { + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + throw new Error('Daemon client connection is incomplete; reconnect') + } + return { + transferId: this.options.historySeedTransfers.start(clientId, request.payload) + } + } + case 'appendHistorySeedTransfer': + this.options.historySeedTransfers.append( + clientId, + request.payload.transferId, + request.payload.index, + request.payload.data + ) + return {} + case 'finishHistorySeedTransfer': + this.options.historySeedTransfers.finish(clientId, request.payload.transferId) + return {} + case 'abortHistorySeedTransfer': + this.options.historySeedTransfers.abort(clientId, request.payload.transferId) + return {} + case 'createOrAttach': + return this.options.admission.createOrAttach(clientId, request) + case 'cancelCreateOrAttach': + return { + canceled: this.options.preparations.cancel(request.payload.sessionId, { + clientId, + ...(typeof request.payload.requestId === 'string' + ? { requestId: request.payload.requestId } + : {}) + }) + } + case 'closeStartupQueryAuthority': + return { + appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId) + } + case 'write': + return this.write(client, request.payload.sessionId, request.payload.data) + case 'resize': + return this.resize( + client, + request.payload.sessionId, + request.payload.cols, + request.payload.rows + ) + case 'pausePty': + this.options.host.pauseProducer(request.payload.sessionId) + return {} + case 'resumePty': + this.options.host.resumeProducer(request.payload.sessionId) + return {} + case 'setSessionBackground': + return this.options.sessionBackgroundRouting.setBackground( + request.payload.sessionId, + request.payload.background === true + ) + case 'kill': + return this.kill(clientId, request.payload.sessionId, request.payload.immediate) + case 'signal': + this.options.host.signal(request.payload.sessionId, request.payload.signal) + return {} + case 'detach': + this.options.attachments.detachSessionForClient(request.payload.sessionId, clientId) + this.options.log.log('session-detached', { sessionId: request.payload.sessionId }) + return {} + case 'getCwd': + return { cwd: await this.options.host.getCwd(request.payload.sessionId) } + case 'getForegroundProcess': + return { + foregroundProcess: this.options.host.getForegroundProcess(request.payload.sessionId) + } + case 'inspectProcess': + return this.options.host.inspectProcess(request.payload.sessionId) + case 'confirmForegroundProcess': + return { + foregroundProcess: await this.options.host.confirmForegroundProcess( + request.payload.sessionId + ) + } + case 'clearScrollback': + this.options.host.clearScrollback(request.payload.sessionId) + return {} + case 'listSessions': + return { sessions: this.options.host.listSessions() } + case 'shutdownIfIdle': + return this.shutdownIfIdle(clientId, request.id) + case 'getSnapshot': + return this.getSnapshot(request.payload.sessionId, request.payload.scrollbackRows) + case 'getSize': + return { size: this.options.host.getAppliedSize(request.payload.sessionId) } + case 'takePendingOutput': + return this.options.host.takePendingOutput( + request.payload.sessionId, + request.payload.includeSnapshot === true, + { teardownSnapshot: request.payload.teardownSnapshot === true } + ) + case 'ping': + return { pong: true } + case 'systemResolverHealth': + return { health: await readCurrentProcessMacSystemResolverHealth() } + case 'ptySpawnHealth': + await this.options.ptySpawnHealthCheck() + return { healthy: true } + case 'shutdown': + return this.shutdown(clientId, request.id, request.payload.killSessions) + } + throw new Error(`Unknown request type: ${(request as { type: string }).type}`) + } + + private write( + client: ConnectedDaemonClient | undefined, + sessionId: string, + data: string + ): Record { + try { + this.options.attachments.recordInput(sessionId) + this.options.host.write(sessionId, data) + } catch (error) { + this.options.attachments.clearInput(sessionId) + if (error instanceof SessionNotFoundError) { + this.sendExitEvent(client, sessionId, -1) + } + throw error + } + return {} + } + + private resize( + client: ConnectedDaemonClient | undefined, + sessionId: string, + cols: number, + rows: number + ): Record { + try { + this.options.host.resize(sessionId, cols, rows) + } catch (error) { + if (error instanceof SessionNotFoundError) { + this.sendExitEvent(client, sessionId, -1) + } + throw error + } + return {} + } + + private async kill( + clientId: string, + sessionId: string, + immediate: boolean | undefined + ): Promise> { + const canceledPendingSpawn = this.options.preparations.cancel(sessionId) + this.options.attachments.clearInput(sessionId) + const attribution = { sessionId, immediate: immediate === true, clientId } + try { + await this.options.host.kill(sessionId, { immediate }) + } catch (error) { + if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { + this.options.log.log('session-kill-failed', attribution) + throw error + } + } + this.options.log.log('session-killed', attribution) + return {} + } + + private shutdownIfIdle(clientId: string, requestId: string): { retiring: boolean } { + const client = this.options.connections.get(clientId) + const retiring = + client !== undefined && + client.streamSocket !== null && + this.options.connections.size === 1 && + this.options.admission.inFlight === 0 && + this.options.host.listSessions().length === 0 && + this.options.connections.hasOnlyTransportsFor(client) + if (retiring) { + this.options.lifecycle.retireAfterIdleReply(clientId, requestId, client.controlSocket) + } + return { retiring } + } + + private getSnapshot(sessionId: string, requestedRows: unknown): unknown { + const startedAt = performance.now() + const scrollbackRows = + typeof requestedRows === 'number' && Number.isFinite(requestedRows) + ? Math.max(0, Math.min(50_000, Math.floor(requestedRows))) + : undefined + const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows }) + const snapshotMs = performance.now() - startedAt + if (snapshotMs >= 25) { + recordDaemonStreamBacklogEvent('slowGetSnapshot', { + sessionIdSuffix: sessionId.slice(-10), + snapshotMs: Math.round(snapshotMs) + }) + } + return { snapshot } + } + + private async shutdown( + clientId: string, + requestId: string, + killSessions: boolean + ): Promise> { + this.options.log.log('shutdown', { + reason: 'rpc', + killSessions: killSessions === true + }) + const serverClose = this.options.lifecycle.beginOrdinaryShutdownFence() + if (killSessions) { + try { + await this.options.host.dispose() + } catch (error) { + this.options.log.log('shutdown-dispose-failed', { + error: error instanceof Error ? error.message : String(error) + }) + } + } + const controlSocket = this.options.connections.get(clientId)?.controlSocket + if (controlSocket) { + this.options.lifecycle.deferRpcShutdownUntilReply( + clientId, + requestId, + controlSocket, + serverClose + ) + } else { + this.options.lifecycle.finishRpcShutdownWithoutReply(serverClose) + } + return {} + } + + private sendExitEvent( + client: ConnectedDaemonClient | undefined, + sessionId: string, + code: number + ): void { + if (!client?.streamSocket) { + return + } + this.options.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { + type: 'event', + event: 'exit', + sessionId, + payload: { code } + }) + this.options.streamDataBatcher.flush(client.clientId) + } +} diff --git a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts index 6440fd123db..57585dc9318 100644 --- a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts +++ b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts @@ -6,6 +6,12 @@ import { DaemonClient } from './client' import { createMockSubprocess } from './daemon-pty-adapter-test-harness' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' +type DaemonServerInternals = { + preparations: { + pending: Map> + } + host: { listSessions: () => unknown[] } +} describe('daemon async spawn cancellation', () => { let directory: string @@ -82,14 +88,11 @@ describe('daemon async spawn cancellation', () => { await spawnStarted client.disconnect() - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - host: { listSessions: () => unknown[] } - } + const daemon = server as unknown as DaemonServerInternals await vi.waitFor(() => - expect( - [...(daemon.pendingPtySpawnPreparations.get('disconnected-spawn') ?? [])][0]?.canceled - ).toBe(true) + expect([...(daemon.preparations.pending.get('disconnected-spawn') ?? [])][0]?.canceled).toBe( + true + ) ) releaseSpawn() await create @@ -145,14 +148,10 @@ describe('daemon async spawn cancellation', () => { 30_000, abort.signal ) - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - } + const daemon = server as unknown as DaemonServerInternals // Attach-only used to register nothing, so the daemon could not match the // cancel and the client dropped its only timeout. - await vi.waitFor(() => - expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2) - ) + await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) abort.abort() await expect(attach).rejects.toThrow('client_disconnected') @@ -175,12 +174,8 @@ describe('daemon async spawn cancellation', () => { abort.signal ) await spawnStarted - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - } - await vi.waitFor(() => - expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2) - ) + const daemon = server as unknown as DaemonServerInternals + await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) abort.abort() await expect(second).rejects.toThrow('client_disconnected') diff --git a/src/main/daemon/daemon-server-attachment-lifecycle.test.ts b/src/main/daemon/daemon-server-attachment-lifecycle.test.ts index 34d78739f13..2f4d1b734ac 100644 --- a/src/main/daemon/daemon-server-attachment-lifecycle.test.ts +++ b/src/main/daemon/daemon-server-attachment-lifecycle.test.ts @@ -35,9 +35,13 @@ type DaemonAttachmentPrivate = { createOrAttach: (options: CreateOrAttachOptions) => Promise sessions: Map } - clients: Map - streamClientIdBySessionId: Map - attachTokenBySessionId: Map + connections: { + clients: Map + } + attachments: { + clientIdBySessionId: Map + tokenBySessionId: Map + } } describe('DaemonServer attachment lifecycle', () => { @@ -73,7 +77,7 @@ describe('DaemonServer attachment lifecycle', () => { await createSession('transport-owned-session') const daemon = server as unknown as DaemonAttachmentPrivate const detachClients = vi.spyOn(daemon.host, 'detachClients') - const streamSocket = [...daemon.clients.values()][0]?.streamSocket + const streamSocket = [...daemon.connections.clients.values()][0]?.streamSocket streamSocket?.destroy() @@ -82,8 +86,8 @@ describe('DaemonServer attachment lifecycle', () => { { sessionId: 'transport-owned-session', token: expect.any(Symbol) } ]) expect(daemon.host.sessions.get('transport-owned-session')?.hasAttachedClients).toBe(false) - expect(daemon.streamClientIdBySessionId.has('transport-owned-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('transport-owned-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('transport-owned-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('transport-owned-session')).toBe(false) }) it('parks only the requesting client attachment on explicit detach', async () => { @@ -95,8 +99,8 @@ describe('DaemonServer attachment lifecycle', () => { expect(detach).toHaveBeenCalledWith('explicitly-detached-session', expect.any(Symbol)) expect(daemon.host.sessions.get('explicitly-detached-session')?.hasAttachedClients).toBe(false) - expect(daemon.streamClientIdBySessionId.has('explicitly-detached-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('explicitly-detached-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('explicitly-detached-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('explicitly-detached-session')).toBe(false) }) it('parks an attachment completed after its transport already closed', async () => { @@ -122,7 +126,7 @@ describe('DaemonServer attachment lifecycle', () => { await created client.disconnect() - await vi.waitFor(() => expect(daemon.clients.size).toBe(0)) + await vi.waitFor(() => expect(daemon.connections.clients.size).toBe(0)) expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(true) finishRequest() @@ -130,7 +134,7 @@ describe('DaemonServer attachment lifecycle', () => { await vi.waitFor(() => expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(false) ) - expect(daemon.streamClientIdBySessionId.has('close-race-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('close-race-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('close-race-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('close-race-session')).toBe(false) }) }) diff --git a/src/main/daemon/daemon-server-error-handling.test.ts b/src/main/daemon/daemon-server-error-handling.test.ts index 3d61551cda0..7ab9d1ffa95 100644 --- a/src/main/daemon/daemon-server-error-handling.test.ts +++ b/src/main/daemon/daemon-server-error-handling.test.ts @@ -1,14 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { chmodSync, linkSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs' +import { chmodSync, linkSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createServer, type Server } from 'node:net' +import { connect, createServer, type Server } from 'node:net' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import { DaemonClient } from './client' -import { isDaemonGoneError } from './daemon-pty-adapter' +import { isDaemonGoneError } from './daemon-endpoint-errors' import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import { encodeNdjson } from './ndjson' import { getDaemonSocketPath } from './daemon-spawner' import type { SubprocessHandle } from './session-subprocess-handle' +import { PROTOCOL_VERSION } from './types' import { waitForEndpointUnreachable } from './daemon-endpoint-reachability-test-harness' // A killed process must actually report its exit: teardown waits @@ -32,6 +35,21 @@ function createMockSubprocess(): SubprocessHandle { dispose() {} } } +type DaemonServerInternals = { + endpoint: { + hasLostOwnership(): boolean + requestRetirementForLoss(): void + checkOwnership(): void + ownershipLossStreak: number + ownershipLost: boolean + } + lifecycle: { + retirementRequested: boolean + state: string + server: Server | null + } + connections: { clients: Map } +} describe('daemon server error handling', () => { let dir: string @@ -88,8 +106,8 @@ describe('daemon server error handling', () => { ).rejects.toThrow(/no longer owns its endpoint/) // And it stands down rather than lingering as an unreachable host. - const daemon = server as unknown as { retirementRequested: boolean } - expect(daemon.retirementRequested).toBe(true) + const daemon = server as unknown as DaemonServerInternals + expect(daemon.lifecycle.retirementRequested).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -108,17 +126,14 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { - hasLostEndpointOwnership: () => boolean - retirementRequested: boolean - } + const daemon = server as unknown as DaemonServerInternals try { // Drop search permission on the directory so the ownership stat fails EACCES. chmodSync(dir, 0o600) - expect(daemon.hasLostEndpointOwnership()).toBe(false) - expect(daemon.retirementRequested).toBe(false) + expect(daemon.endpoint.hasLostOwnership()).toBe(false) + expect(daemon.lifecycle.retirementRequested).toBe(false) } finally { chmodSync(dir, 0o700) } @@ -150,8 +165,8 @@ describe('daemon server error handling', () => { linkSync(usurperBind, socketPath) // The guard is still armed here — no creation has been refused yet. - const daemon = server as unknown as { hasLostEndpointOwnership: () => boolean } - expect(daemon.hasLostEndpointOwnership()).toBe(true) + const daemon = server as unknown as DaemonServerInternals + expect(daemon.endpoint.hasLostOwnership()).toBe(true) await expect( client.request('createOrAttach', { @@ -198,14 +213,11 @@ describe('daemon server error handling', () => { ).rejects.toThrow(/no longer owns its endpoint/) // Simulate a late client completing its handshake and clearing pending retirement. - const daemon = server as unknown as { - retirementRequested: boolean - hasLostEndpointOwnership: () => boolean - } - daemon.retirementRequested = false + const daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.retirementRequested = false // The loss must still be remembered, so creation stays closed. - expect(daemon.hasLostEndpointOwnership()).toBe(true) + expect(daemon.endpoint.hasLostOwnership()).toBe(true) await expect( client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 }) ).rejects.toThrow(/no longer owns its endpoint/) @@ -248,16 +260,13 @@ describe('daemon server error handling', () => { // Losing the endpoint with nothing left to drain must stand the daemon down, even // though this client is still connected. - const daemon = server as unknown as { - requestRetirementForLostEndpoint: () => void - idleShutdownState: string - } - expect(daemon.idleShutdownState).toBe('running') - daemon.requestRetirementForLostEndpoint() + const daemon = server as unknown as DaemonServerInternals + expect(daemon.lifecycle.state).toBe('running') + daemon.endpoint.requestRetirementForLoss() // Drained and unreachable: it must begin standing down rather than wait for this // client, which can never make it routable again. - expect(daemon.idleShutdownState).not.toBe('running') + expect(daemon.lifecycle.state).not.toBe('running') } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -320,19 +329,15 @@ describe('daemon server error handling', () => { unlinkSync(socketPath) linkSync(usurperBind, socketPath) - const daemon = server as unknown as { - retirementRequested: boolean - endpointOwnershipLost: boolean - checkEndpointOwnership: () => void - } + const daemon = server as unknown as DaemonServerInternals // Retirement is already pending for an unrelated reason. - daemon.retirementRequested = true + daemon.lifecycle.retirementRequested = true - daemon.checkEndpointOwnership() - daemon.checkEndpointOwnership() + daemon.endpoint.checkOwnership() + daemon.endpoint.checkOwnership() // The loss must be recorded regardless, so a later hello cannot undo it. - expect(daemon.endpointOwnershipLost).toBe(true) + expect(daemon.endpoint.ownershipLost).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -352,12 +357,7 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { - checkEndpointOwnership: () => void - hasLostEndpointOwnership: () => boolean - endpointOwnershipLost: boolean - endpointOwnershipLossStreak: number - } + const daemon = server as unknown as DaemonServerInternals // A second name for our own socket inode, so ownership can be handed back. const ourAlias = join(dir, '.ours') linkSync(socketPath, ourAlias) @@ -369,21 +369,21 @@ describe('daemon server error handling', () => { // First loss. unlinkSync(socketPath) linkSync(usurperBind, socketPath) - daemon.checkEndpointOwnership() - expect(daemon.endpointOwnershipLossStreak).toBe(1) - expect(daemon.endpointOwnershipLost).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.endpoint.ownershipLossStreak).toBe(1) + expect(daemon.endpoint.ownershipLost).toBe(false) // Ownership demonstrably returns, observed through the admission path. unlinkSync(socketPath) linkSync(ourAlias, socketPath) - expect(daemon.hasLostEndpointOwnership()).toBe(false) - expect(daemon.endpointOwnershipLossStreak).toBe(0) + expect(daemon.endpoint.hasLostOwnership()).toBe(false) + expect(daemon.endpoint.ownershipLossStreak).toBe(0) // A later isolated loss is confirmation #1, not #2, so it must not retire. unlinkSync(socketPath) linkSync(usurperBind, socketPath) - daemon.checkEndpointOwnership() - expect(daemon.endpointOwnershipLost).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.endpoint.ownershipLost).toBe(false) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -400,19 +400,21 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { server: Server | null } + const daemon = server as unknown as DaemonServerInternals - expect(daemon.server?.listenerCount('error')).toBe(1) + expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { // Twice on purpose: a one-shot listener survives the first error and dies on the second, // so a single emit cannot tell a permanent handler from `once`. - expect(() => daemon.server?.emit('error', new Error('EMFILE: accept failed'))).not.toThrow() expect(() => - daemon.server?.emit('error', new Error('EMFILE: accept failed again')) + daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed')) + ).not.toThrow() + expect(() => + daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed again')) ).not.toThrow() expect(warn).toHaveBeenCalledTimes(2) - expect(daemon.server?.listenerCount('error')).toBe(1) + expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1) } finally { warn.mockRestore() } @@ -422,6 +424,44 @@ describe('daemon server error handling', () => { expect(client.isConnected()).toBe(true) }) + it('rejects unsupported hello roles without replacing an authenticated stream', async () => { + const onAuthenticatedClientPair = vi.fn() + server = new DaemonServer({ + socketPath, + tokenPath, + onAuthenticatedClientPair, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as DaemonServerInternals + const connectedClient = [...daemon.connections.clients.values()][0] + const originalStream = connectedClient.streamSocket + + const unsupported = connect(socketPath) + unsupported.on('error', () => {}) + await new Promise((resolve) => unsupported.once('connect', resolve)) + const response = new Promise<{ ok: boolean; error?: string }>((resolve) => { + unsupported.once('data', (data) => resolve(JSON.parse(data.toString()))) + }) + unsupported.write( + encodeNdjson({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8'), + clientId: connectedClient.clientId, + role: 'observer' + }) + ) + await expect(response).resolves.toMatchObject({ ok: false, error: 'Invalid role' }) + unsupported.destroy() + + expect(connectedClient.streamSocket).toBe(originalStream) + expect(connectedClient.authenticatedPairEstablished).toBe(true) + expect(onAuthenticatedClientPair).toHaveBeenCalledOnce() + }) + it.skipIf(process.platform === 'win32')( 'stands down instead of serving when the server errors while publication is in flight', async () => { @@ -443,14 +483,14 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) const started = server.start() - const daemon = server as unknown as { server: Server | null } - await vi.waitFor(() => expect(daemon.server?.listening).toBe(true)) - daemon.server?.emit('error', new Error('injected accept failure')) + const daemon = server as unknown as DaemonServerInternals + await vi.waitFor(() => expect(daemon.lifecycle.server?.listening).toBe(true)) + daemon.lifecycle.server?.emit('error', new Error('injected accept failure')) await expect(started).rejects.toThrow('injected accept failure') // Why wait: start() rejects the moment the error lands, while publication is still in // flight. Reporting failure must end with the daemon actually not serving. - await vi.waitFor(() => expect(daemon.server).toBeNull()) + await vi.waitFor(() => expect(daemon.lifecycle.server).toBeNull()) await expect(waitForEndpointUnreachable(socketPath)).resolves.toBe(true) } ) diff --git a/src/main/daemon/daemon-server-kill-attribution.test.ts b/src/main/daemon/daemon-server-kill-attribution.test.ts index 2c4f903ce38..8fa6805e862 100644 --- a/src/main/daemon/daemon-server-kill-attribution.test.ts +++ b/src/main/daemon/daemon-server-kill-attribution.test.ts @@ -7,8 +7,12 @@ import { SessionNotFoundError, type DaemonRequest } from './types' type DaemonServerPrivate = { host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise } - pendingPtySpawnPreparations: Map> - routeRequest(clientId: string, request: DaemonRequest): Promise + preparations: { + pending: Map> + } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise + } } describe('daemon kill attribution', () => { @@ -38,7 +42,7 @@ describe('daemon kill attribution', () => { }) vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished) - const request = daemon.routeRequest('control-42', { + const request = daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -71,7 +75,7 @@ describe('daemon kill attribution', () => { vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused')) await expect( - daemon.routeRequest('control-42', { + daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -103,10 +107,10 @@ describe('daemon kill attribution', () => { controller: new AbortController(), clientId: 'control-42' } - daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation])) + daemon.preparations.pending.set('agent-session', new Set([pendingPreparation])) vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session')) - await daemon.routeRequest('control-42', { + await daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } diff --git a/src/main/daemon/daemon-server-lifecycle.ts b/src/main/daemon/daemon-server-lifecycle.ts new file mode 100644 index 00000000000..0d4379b95e0 --- /dev/null +++ b/src/main/daemon/daemon-server-lifecycle.ts @@ -0,0 +1,278 @@ +import { createServer, type Server, type Socket } from 'node:net' +import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' +import type { DaemonFileLog } from './daemon-file-log' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' + +type PendingShutdownReply = { start: () => void } + +type DaemonServerLifecycleOptions = { + protocolVersion: number + initialAdoptionTimeoutMs: number + clock: { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number + } + endpoint: DaemonEndpointLifecycle + log: DaemonFileLog + isIdle: () => boolean + disposeResources: () => Promise + onIdleShutdown: () => void + onRpcShutdown: () => void +} + +export class DaemonServerLifecycle { + private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 + private server: Server | null = null + private startupFailure: Error | null = null + private state: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' + private initialAdoptionTimer: unknown = null + private initialAdoptionDeadlineMs: number | null = null + private retirementRequested = false + private shutdownPromise: Promise | null = null + private ordinaryShutdownServerClose: Promise | null = null + private readonly pendingShutdownReplies = new Map() + + constructor(private readonly options: DaemonServerLifecycleOptions) {} + + isAcceptingWork(): boolean { + return this.state === 'running' + } + + async start(handleConnection: (socket: Socket) => void): Promise { + return new Promise((resolve, reject) => { + this.server = createServer(handleConnection) + let startupSettled = false + const onServerError = (error: Error): void => { + if (startupSettled) { + this.options.log.log('server-error', { message: error.message }) + console.warn(`[daemon] Socket server error: ${error.message}`) + return + } + startupSettled = true + this.startupFailure = error + reject(error) + } + this.server.on('error', onServerError) + const bindPath = this.options.endpoint.bindPath() + this.server.listen(bindPath, () => { + this.options.endpoint.secureBindPath(bindPath) + const abandonStartup = (error: unknown): void => { + startupSettled = true + const server = this.server + this.server = null + reject(error) + server?.close() + this.options.endpoint.abandonBindPath(bindPath) + } + void this.options.endpoint.publish(bindPath).then(() => { + if (this.startupFailure) { + this.options.endpoint.retireUnstarted() + this.cancelInitialAdoptionTimer() + abandonStartup(this.startupFailure) + return + } + if (this.options.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + this.armInitialAdoptionTimeout() + } + this.options.endpoint.startOwnershipWatch() + startupSettled = true + resolve() + }, abandonStartup) + }) + }) + } + + shutdown(): Promise { + if (!this.shutdownPromise) { + const serverClose = this.beginOrdinaryShutdownFence() + this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + } + return this.shutdownPromise + } + + onConnectionAccepted(): void { + this.cancelInitialAdoptionTimer() + } + + onAuthenticatedPair(): void { + this.initialAdoptionDeadlineMs = null + this.cancelInitialAdoptionTimer() + if (!this.options.endpoint.lost) { + this.retirementRequested = false + } + } + + onLastAuthenticatedClientDisconnected(): void { + if (this.state === 'running') { + this.retirementRequested = true + } + } + + onEndpointOwnershipLost(): void { + this.retirementRequested = true + this.reevaluateIdleShutdown() + } + + reevaluateIdleShutdown(): void { + if (this.state !== 'running') { + return + } + if (this.retirementRequested) { + this.cancelInitialAdoptionTimer() + if (this.options.isIdle()) { + this.beginIdleShutdown() + } + return + } + if (!this.options.isIdle() || this.initialAdoptionDeadlineMs === null) { + this.cancelInitialAdoptionTimer() + return + } + if (this.initialAdoptionTimer !== null) { + return + } + const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.options.clock.now()) + if (remainingMs === 0) { + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + return + } + this.initialAdoptionTimer = this.options.clock.setTimeout(() => { + this.initialAdoptionTimer = null + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + }, remainingMs) + } + + beginOrdinaryShutdownFence(): Promise { + this.state = 'shutting-down' + this.cancelInitialAdoptionTimer() + this.ordinaryShutdownServerClose ??= this.beginServerClose() + return this.ordinaryShutdownServerClose + } + + deferRpcShutdownUntilReply( + clientId: string, + requestId: string, + socket: Socket, + serverClose: Promise + ): void { + this.deferShutdownUntilReply(clientId, requestId, socket, () => + this.finishRpcShutdown(serverClose) + ) + } + finishRpcShutdownWithoutReply(serverClose: Promise): void { + this.shutdownPromise ??= this.finishRpcShutdown(serverClose) + } + + retireAfterIdleReply(clientId: string, requestId: string, socket: Socket): void { + this.state = 'shutting-down' + this.initialAdoptionDeadlineMs = null + this.retirementRequested = false + this.cancelInitialAdoptionTimer() + const serverClose = this.beginServerClose() + this.deferShutdownUntilReply(clientId, requestId, socket, () => + this.finishIdleShutdown(serverClose) + ) + } + + startPendingShutdownReply(clientId: string, requestId: string): void { + this.pendingShutdownReplies.get(this.shutdownReplyKey(clientId, requestId))?.start() + } + + private armInitialAdoptionTimeout(): void { + this.initialAdoptionDeadlineMs = + this.options.clock.now() + this.options.initialAdoptionTimeoutMs + this.reevaluateIdleShutdown() + } + + private cancelInitialAdoptionTimer(): void { + if (this.initialAdoptionTimer === null) { + return + } + this.options.clock.clearTimeout(this.initialAdoptionTimer) + this.initialAdoptionTimer = null + } + + private beginIdleShutdown(): void { + this.initialAdoptionTimer = null + if (this.state !== 'running') { + return + } + this.state = 'idle-shutdown-pending' + if (!this.options.isIdle()) { + this.state = 'running' + this.reevaluateIdleShutdown() + return + } + this.state = 'shutting-down' + const serverClose = this.beginServerClose() + this.shutdownPromise = this.finishIdleShutdown(serverClose) + } + + private async finishOrdinaryShutdown(serverClose: Promise): Promise { + this.options.endpoint.unlinkOwnedArtifacts() + await this.disposeResources() + await serverClose + } + + private async finishRpcShutdown(serverClose: Promise): Promise { + await this.finishOrdinaryShutdown(serverClose) + this.options.onRpcShutdown() + } + + private async finishIdleShutdown(serverClose: Promise): Promise { + this.options.endpoint.unlinkOwnedArtifacts() + await this.disposeResources() + await serverClose + this.options.onIdleShutdown() + } + + private async disposeResources(): Promise { + await this.options.disposeResources() + this.pendingShutdownReplies.clear() + } + + private beginServerClose(): Promise { + const server = this.server + this.server = null + if (!server) { + return Promise.resolve() + } + return new Promise((resolve) => server.close(() => resolve())) + } + + 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) + this.shutdownPromise ??= finish() + } + timer = setTimeout(start, DaemonServerLifecycle.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS) + timer.unref() + socket.once('close', start) + socket.once('error', start) + this.pendingShutdownReplies.set(key, { start }) + } + + private shutdownReplyKey(clientId: string, requestId: string): string { + return `${clientId}\u0000${requestId}` + } +} diff --git a/src/main/daemon/daemon-server-options.ts b/src/main/daemon/daemon-server-options.ts new file mode 100644 index 00000000000..06f2cda6cb9 --- /dev/null +++ b/src/main/daemon/daemon-server-options.ts @@ -0,0 +1,40 @@ +import type { DaemonFileLog } from './daemon-file-log' +import type { SubprocessHandle } from './session-subprocess-handle' + +export type DaemonServerOptions = { + socketPath: string + tokenPath: string + pidPath?: string + launchNonce?: string + startedAtMs?: number + publishEndpointOwnership?: () => void + entryPath?: string + appVersion?: string + spawnerExecPath?: string + protocolVersion?: number + onIdleShutdown?: () => void + onRpcShutdown?: () => void + initialAdoptionTestConfig?: { + timeoutMs: number + clock: { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number + } + } + ptySpawnHealthCheck?: () => Promise + preparePtySpawn?: () => Promise + onPtySessionExit?: (sessionId: string) => void + onAuthenticatedClientPair?: () => void + log?: DaemonFileLog + spawnSubprocess: (opts: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string + shellOverride?: string + isCanceled?: () => boolean + }) => SubprocessHandle | Promise +} diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 1eaafb78ffa..27727aa2b3d 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' import { PROTOCOL_VERSION, type DaemonRequest } from './types' @@ -50,21 +51,16 @@ function createMockSubprocess(): SubprocessHandle & { } type DaemonServerPrivate = { - server: Server | null - pendingPtySpawnPreparations: Map> + lifecycle: { server: Server | null } + preparations: { pending: Map> } host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise + dispose: () => Promise + } + connections: { clients: Map } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise } - clients: Map< - string, - { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean - } - > - routeRequest(clientId: string, request: DaemonRequest): Promise } describe('DaemonServer', () => { @@ -345,11 +341,11 @@ describe('DaemonServer', () => { // Wait for the server to process the close (and cancel the prep) before // releasing the preflight, else the resumed spawn races ahead of cancellation. await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).clients.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).connections.clients.size).toBe(0) ) finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).not.toHaveBeenCalled() }) @@ -486,8 +482,8 @@ describe('DaemonServer', () => { const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown) let acknowledged = false - const routed = daemon - .routeRequest('client-1', { + const routed = daemon.requestRouter + .route('client-1', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -588,14 +584,14 @@ describe('DaemonServer', () => { write: vi.fn() } as unknown as Socket & { write: ReturnType } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -608,7 +604,7 @@ describe('DaemonServer', () => { expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"background"') streamSocket.write.mockClear() - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-2', type: 'write', payload: { sessionId: 'test-session', data: 'x' } @@ -645,14 +641,14 @@ describe('DaemonServer', () => { write: vi.fn() } as unknown as Socket & { write: ReturnType } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -703,13 +699,13 @@ describe('DaemonServer', () => { writableLength: number } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -776,11 +772,11 @@ describe('DaemonServer', () => { const control = await connectRawHello('control', 'raw-client') const stream = await connectRawHello('stream', 'raw-client') - expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy() stream.destroy() - await waitFor(() => daemon.clients.get('raw-client')?.streamSocket === null) + await waitFor(() => daemon.connections.clients.get('raw-client')?.streamSocket === null) control.destroy() }) @@ -797,7 +793,7 @@ describe('DaemonServer', () => { const secondStream = await connectRawHello('stream', 'raw-client') await waitFor(() => firstClosed) - expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy() secondStream.destroy() control.destroy() }) @@ -819,8 +815,8 @@ describe('DaemonServer', () => { const secondControl = await connectRawHello('control', 'raw-client') await waitFor(() => firstControlClosed && firstStreamClosed) - expect(daemon.clients.get('raw-client')?.controlSocket).toBeTruthy() - expect(daemon.clients.get('raw-client')?.streamSocket).toBeNull() + expect(daemon.connections.clients.get('raw-client')?.controlSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeNull() secondControl.destroy() }) @@ -834,7 +830,7 @@ describe('DaemonServer', () => { }) await waitFor(() => closed || stream.destroyed) - expect(daemon.clients.has('missing-client')).toBe(false) + expect(daemon.connections.clients.has('missing-client')).toBe(false) }) }) @@ -846,7 +842,7 @@ describe('DaemonServer', () => { const daemon = server as unknown as DaemonServerPrivate & { host: { dispose: () => Promise } } - const controlSocket = [...daemon.clients.values()][0].controlSocket + const controlSocket = [...daemon.connections.clients.values()][0].controlSocket const originalWrite = controlSocket.write.bind(controlSocket) let replyFlushed: (() => void) | undefined vi.spyOn(controlSocket, 'write').mockImplementation((( @@ -928,7 +924,7 @@ describe('DaemonServer', () => { const c = await connectClient() await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({}) - await waitFor(() => daemon.server === null) + await waitFor(() => daemon.lifecycle.server === null) // Why not existsSync: the dead entry remains for the next publisher to replace. expect(await waitForEndpointUnreachable(socketPath)).toBe(true) const late = new DaemonClient({ socketPath, tokenPath }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index b276b3cb0ba..21514b0a850 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,854 +1,255 @@ -/* eslint-disable max-lines -- Why: one class owns the daemon socket protocol, routing, stream fanout, and session lifecycle. */ -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 { StringDecoder } from 'node:string_decoder' -import { encodeNdjson, createNdjsonParser } from './ndjson' -import { TerminalHost } from './terminal-host' -import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { Socket } from 'node:net' +import { setImmediate as waitForImmediate } from 'node:timers/promises' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' import { BackgroundTransientFactRelay, BACKGROUND_STREAM_DROP_ENABLED } from './daemon-background-transient-facts' -import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' -import { - recordDaemonStreamBacklogEvent, - startDaemonStreamBacklogProbe -} from './daemon-stream-backlog-probe' -import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' -import type { SubprocessHandle } from './session-subprocess-handle' -import { checkPtySpawnHealth } from './pty-subprocess' +import { DaemonClientConnections } from './daemon-client-connections' +import { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' -import { isTuiAgent } from '../../shared/tui-agent-config' -import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' -import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' -import { - DAEMON_ENDPOINT_LOST_MESSAGE, - DaemonEndpointUnavailableError, - type DaemonEndpointOwnershipState, - getDaemonSocketBindPath, - publishDaemonEndpoint, - readDaemonEndpointOwnershipState, - type DaemonSocketIdentity -} from './daemon-endpoint-ownership' -import { probeSocketConnect } from './daemon-endpoint-probe' +import { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations' +import { DaemonRequestRouter } from './daemon-request-router' +import { DaemonServerLifecycle } from './daemon-server-lifecycle' +import type { DaemonServerOptions } from './daemon-server-options' +import { DaemonSessionAttachments } from './daemon-session-attachments' +import { DaemonSessionBackgroundRouting } from './daemon-session-background-routing' +import { startDaemonStreamBacklogProbe } from './daemon-stream-backlog-probe' +import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { DaemonTerminalAdmission } from './daemon-terminal-admission' +import { checkPtySpawnHealth } from './pty-subprocess' +import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import { TerminalHost } from './terminal-host' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, - PROTOCOL_VERSION, NOTIFY_PREFIX, - SessionNotFoundError, - TerminalAttachCanceledError, - type HelloMessage, + PROTOCOL_VERSION, type DaemonRequest } from './types' -import { - isAgentSessionExecutionClaim, - isAgentSessionSurfaceBinding -} from '../../shared/agent-session-host-authority' -import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' - -export type DaemonServerOptions = { - socketPath: string - tokenPath: string - pidPath?: string - launchNonce?: string - startedAtMs?: number - publishEndpointOwnership?: () => void - /** Reported in the hello so a repaired PID record can carry the real owner's metadata. */ - entryPath?: string - appVersion?: string - spawnerExecPath?: string - /** Direct-construction seam for protocol fixture tests; production never overrides it. */ - protocolVersion?: number - onIdleShutdown?: () => void - onRpcShutdown?: () => 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 - // Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections. - onPtySessionExit?: (sessionId: string) => void - onAuthenticatedClientPair?: () => void - log?: DaemonFileLog - spawnSubprocess: (opts: { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - command?: string - shellOverride?: string - isCanceled?: () => boolean - // Async production spawns and sync test stubs share this boundary. - }) => SubprocessHandle | Promise -} - -type ConnectedClient = { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean -} - -type PendingPtySpawnPreparation = { - canceled: boolean - // Why: `canceled` is only polled between spawn steps; the signal is what - // actually interrupts an in-progress cwd probe on a dead share. - readonly controller: AbortController - cancelTimer?: ReturnType - // Why: preparations are keyed by sessionId, but a control-socket close must - // cancel only the disconnecting client's preps, not another client's (F4). - clientId: string - requestId: string -} - -function cancelPtySpawnPreparation(preparation: PendingPtySpawnPreparation): void { - preparation.canceled = true - preparation.controller.abort() -} - -type PendingShutdownReply = { - start: () => void -} +import { encodeNdjson } from './ndjson' export class DaemonServer { - // Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first. private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 - private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 - private static readonly ENDPOINT_OWNERSHIP_POLL_MS = 30 * 1000 - private static readonly ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS = 2 - 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 publishEndpointOwnership: () => void - private entryPath: string | null - private appVersion: string | null - private spawnerExecPath: string | null - private ownedSocketIdentity: DaemonSocketIdentity | null = null - /** Set once start() has been rejected, so async publication can tell it is no longer wanted. */ - private startupFailure: Error | null = null - private endpointOwnershipTimer: ReturnType | null = null - private endpointOwnershipLossStreak = 0 - /** - * Sticky, and not inferred from `ownedSocketIdentity`: retiring nulls that, so a socket accepted - * before the takeover could finish its hello and reopen sessions on an unreachable daemon. - */ - private endpointOwnershipLost = false - private protocolVersion: number - private onIdleShutdown: () => void - private onRpcShutdown: () => void - private onAuthenticatedClientPair: () => 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 - 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( - (clientId) => this.clients.get(clientId), - { - isSessionDroppable: (sessionId) => - BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), - salvageDroppedData: (dropped) => { - if (!dropped.includes('\x1b')) { - return '' - } - const extracted = extractHiddenStartupRendererQueryData(dropped, '') - return ( - extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData - ) - } - } - ) - // Facts ride the stream queue as control entries so they hold byte order (else a fact could arrive after the reveal snapshot). - private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { - const clientId = this.streamClientIdBySessionId.get(sessionId) - if (clientId) { - this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { - type: 'event', - event: 'transientFact', - sessionId, - payload: fact - }) - } - }) - private streamClientIdBySessionId = new Map() - private attachTokenBySessionId = new Map() - private lastInputAtBySessionId = new Map() - private pendingPtySpawnPreparations = new Map>() - private historySeedTransfers = new TerminalHistorySeedTransferRegistry() + private readonly log: DaemonFileLog + private readonly host: TerminalHost + private readonly transientFactRelay: BackgroundTransientFactRelay + private readonly streamDataBatcher: DaemonStreamDataBatcher + private readonly attachments: DaemonSessionAttachments + private readonly historySeedTransfers = new TerminalHistorySeedTransferRegistry() + private readonly preparations: DaemonPtySpawnPreparations + private readonly connections: DaemonClientConnections + private readonly endpoint: DaemonEndpointLifecycle + private readonly lifecycle: DaemonServerLifecycle + private readonly admission: DaemonTerminalAdmission + private readonly requestRouter: DaemonRequestRouter private stopStreamBacklogProbe: () => void = () => {} - // Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay. - private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100 - private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + constructor(options: DaemonServerOptions) { + this.log = options.log ?? createNoopDaemonFileLog() + this.host = new TerminalHost({ + spawnSubprocess: options.spawnSubprocess, + reportReadinessEvent: (event, details) => this.log.log(event, details), + onSessionReaped: (sessionId) => { + this.attachments.release(sessionId) + this.transientFactRelay.onSessionExit(sessionId) + this.streamDataBatcher.refreshSessionDroppability(sessionId) + options.onPtySessionExit?.(sessionId) + this.lifecycle.reevaluateIdleShutdown() + } + }) + this.attachments = new DaemonSessionAttachments(this.host) + this.transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { + const clientId = this.attachments.clientIdForSession(sessionId) + if (clientId) { + this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'transientFact', + sessionId, + payload: fact + }) + } + }) + this.streamDataBatcher = new DaemonStreamDataBatcher( + (clientId) => this.connections.get(clientId), + { + isSessionDroppable: (sessionId) => + BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), + salvageDroppedData: (dropped) => { + if (!dropped.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(dropped, '') + return ( + extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + ) + } + } + ) + this.preparations = new DaemonPtySpawnPreparations( + options.preparePtySpawn ?? (() => Promise.resolve()) + ) - 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 + const protocolVersion = options.protocolVersion ?? PROTOCOL_VERSION + const launchNonce = + options.launchNonce ?? + (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null) + const startedAtMs = + options.startedAtMs ?? + (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? Date.now() - process.uptime() * 1000 : null) - this.publishEndpointOwnership = opts.publishEndpointOwnership ?? (() => {}) - this.entryPath = opts.entryPath ?? null - this.appVersion = opts.appVersion ?? null - this.spawnerExecPath = opts.spawnerExecPath ?? null - this.onIdleShutdown = opts.onIdleShutdown ?? (() => {}) - this.onRpcShutdown = opts.onRpcShutdown ?? (() => {}) - this.initialAdoptionTimeoutMs = - opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS - this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? { - setTimeout: (callback, delayMs) => { + const token = randomUUID() + + this.connections = new DaemonClientConnections({ + token, + protocolVersion, + identity: { + launchNonce, + startedAtMs, + entryPath: options.entryPath ?? null, + appVersion: options.appVersion ?? null, + spawnerExecPath: options.spawnerExecPath ?? null + }, + log: this.log, + streamDataBatcher: this.streamDataBatcher, + isAcceptingWork: () => this.lifecycle.isAcceptingWork(), + onTransportChanged: () => this.lifecycle.reevaluateIdleShutdown(), + onConnectionAccepted: () => this.lifecycle.onConnectionAccepted(), + onAuthenticatedPair: () => { + options.onAuthenticatedClientPair?.() + this.lifecycle.onAuthenticatedPair() + }, + onLastAuthenticatedClientDisconnected: () => + this.lifecycle.onLastAuthenticatedClientDisconnected(), + onControlRequest: (socket, clientId, request) => + void this.handleRequest(socket, clientId, request), + onControlReplaced: (clientId) => { + this.preparations.cancelForClient(clientId) + this.historySeedTransfers.clearOwner(clientId) + }, + onClientDisconnected: (clientId) => { + this.preparations.cancelForClient(clientId) + this.historySeedTransfers.clearOwner(clientId) + this.streamDataBatcher.clear(clientId) + this.attachments.detachClientSessions(clientId) + }, + onStreamDisconnected: (clientId) => { + this.preparations.cancelForClient(clientId) + this.streamDataBatcher.clear(clientId) + this.attachments.detachClientSessions(clientId) + } + }) + this.endpoint = new DaemonEndpointLifecycle({ + socketPath: options.socketPath, + tokenPath: options.tokenPath, + pidPath: options.pidPath ?? null, + launchNonce, + token, + publishEndpointOwnership: options.publishEndpointOwnership ?? (() => {}), + log: this.log, + isServing: () => this.lifecycle.isAcceptingWork(), + onOwnershipLost: () => this.lifecycle.onEndpointOwnershipLost() + }) + const lifecycleClock = options.initialAdoptionTestConfig?.clock ?? { + setTimeout: (callback: () => void, delayMs: number) => { const timer = setTimeout(callback, delayMs) timer.unref() return timer }, - clearTimeout: (handle) => clearTimeout(handle as ReturnType), + clearTimeout: (handle: unknown) => clearTimeout(handle as ReturnType), now: () => Date.now() } - this.token = randomUUID() - this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {}) - this.host = new TerminalHost({ - spawnSubprocess: opts.spawnSubprocess, - // Why a closure: this.log is assigned after this constructor call, and the - // callback only fires once a session is live. - reportReadinessEvent: (event, details) => this.log.log(event, details), - // Why host-level and not the attach callback: a session whose client transport already dropped - // has no attachment left to fire exit bookkeeping, and the daemon must still notice it can idle. - onSessionReaped: (sessionId) => { - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - this.lastInputAtBySessionId.delete(sessionId) - this.transientFactRelay.onSessionExit(sessionId) - this.streamDataBatcher.refreshSessionDroppability(sessionId) - opts.onPtySessionExit?.(sessionId) - this.reevaluateIdleShutdown() - } + this.lifecycle = new DaemonServerLifecycle({ + protocolVersion, + initialAdoptionTimeoutMs: + options.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS, + clock: lifecycleClock, + endpoint: this.endpoint, + log: this.log, + isIdle: () => this.isIdle(), + disposeResources: () => this.disposeResources(), + onIdleShutdown: options.onIdleShutdown ?? (() => {}), + onRpcShutdown: options.onRpcShutdown ?? (() => {}) + }) + this.admission = new DaemonTerminalAdmission({ + host: this.host, + connections: this.connections, + endpoint: this.endpoint, + preparations: this.preparations, + attachments: this.attachments, + historySeedTransfers: this.historySeedTransfers, + transientFactRelay: this.transientFactRelay, + streamDataBatcher: this.streamDataBatcher, + log: this.log, + isAcceptingWork: () => this.lifecycle.isAcceptingWork(), + requestEndpointRetirement: () => this.endpoint.requestRetirementForLoss(), + reevaluateIdleShutdown: () => this.lifecycle.reevaluateIdleShutdown() + }) + this.requestRouter = new DaemonRequestRouter({ + host: this.host, + connections: this.connections, + lifecycle: this.lifecycle, + admission: this.admission, + preparations: this.preparations, + attachments: this.attachments, + historySeedTransfers: this.historySeedTransfers, + sessionBackgroundRouting: new DaemonSessionBackgroundRouting({ + host: this.host, + attachments: this.attachments, + transientFactRelay: this.transientFactRelay, + streamDataBatcher: this.streamDataBatcher + }), + streamDataBatcher: this.streamDataBatcher, + ptySpawnHealthCheck: options.ptySpawnHealthCheck ?? checkPtySpawnHealth, + log: this.log }) - this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth - this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve()) this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ - clients: Array.from(this.clients.values(), (client) => ({ + clients: Array.from(this.connections.values(), (client) => ({ clientId: client.clientId, socketBufferedBytes: client.streamSocket?.writableLength ?? 0, batcherQueuedChars: this.streamDataBatcher.queuedCharsForClient(client.clientId) })), backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes() })) - this.log = opts.log ?? createNoopDaemonFileLog() } - async start(): Promise { - return new Promise((resolve, reject) => { - this.server = createServer((socket) => this.handleConnection(socket)) - // Permanent, not `once`: an unhandled 'error' is an uncaught exception, so a post-startup - // accept failure (EMFILE) would kill a daemon hosting every terminal. - let startupSettled = false - const onServerError = (err: Error): void => { - if (startupSettled) { - // The daemon is serving; an operational error must not read as a startup failure. - this.log.log('server-error', { message: err.message }) - console.warn(`[daemon] Socket server error: ${err.message}`) - return - } - startupSettled = true - // Publishing is async, so the continuation reads this and tears down instead of arming - // a daemon whose caller was already told startup failed. - this.startupFailure = err - reject(err) - } - - this.server.on('error', onServerError) - - // Private bind name, linked into place: libuv's close-time unlink can then only ever - // remove our own name, never a replacement daemon's endpoint. - const bindPath = - process.platform === 'win32' ? this.socketPath : getDaemonSocketBindPath(this.socketPath) - - this.server.listen(bindPath, () => { - try { - // So the endpoint is never briefly reachable with default permissions. - chmodSync(bindPath, 0o600) - } catch { - // Best-effort on platforms that support it - } - const abandonStartup = (error: unknown): void => { - startupSettled = true - const server = this.server - this.server = null - // Settle before close: an accepted connection defers the close callback indefinitely. - reject(error) - server?.close() - if (process.platform !== 'win32') { - try { - unlinkSync(bindPath) - } catch { - // Already consumed by a successful link or rename, or never created. - } - } - } - void this.publishAndArm(bindPath).then(() => { - // The server can fail while publishing awaits its probe; resolving then would strand a - // live daemon behind a caller told startup failed, who would launch a replacement. - if (this.startupFailure) { - this.retireUnstartedDaemon() - abandonStartup(this.startupFailure) - return - } - // Serving from here, so later server errors are logged, not treated as startup failure. - startupSettled = true - resolve() - }, abandonStartup) - }) - }) + start(): Promise { + return this.lifecycle.start((socket) => this.connections.accept(socket)) } - /** - * Takes the canonical endpoint, then makes this listener adoptable — in that order. Never rolled - * back: an aborting daemon just closes, and the next publisher replaces the dead entry. - */ - private async publishAndArm(bindPath: string): Promise { - const outcome = await publishDaemonEndpoint(bindPath, this.socketPath, probeSocketConnect) - if (outcome.status !== 'published') { - // The only point the design declines to serve, so a field regression surfaces here. - this.log.log('endpoint-publish-declined', { reason: outcome.status }) - console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`) - throw new DaemonEndpointUnavailableError(outcome.status) + shutdown(): Promise { + return this.lifecycle.shutdown() + } + + private isIdle(): boolean { + if (this.admission.inFlight > 0 || this.host.listSessions().length > 0) { + return false } - this.ownedSocketIdentity = outcome.identity - let publishedOwnership = false - try { - // The PID/nonce record must exist before the token makes this listener adoptable. - this.publishEndpointOwnership() - publishedOwnership = true - writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) - } catch (error) { - // Roll back only a record we wrote; anything else at that path belongs to another daemon. - if (publishedOwnership && this.pidPath && this.launchNonce) { - unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) - } - this.ownedSocketIdentity = null - throw error - } - if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { - // A parent crash before the first client pair must not strand an empty daemon forever. - this.armInitialAdoptionTimeout() - } - this.startEndpointOwnershipWatch() - } - - /** - * Stands down a daemon that published after its startup was already reported failed. The - * endpoint stays, as on every exit path — removing it could delete a replacement's name. - */ - private retireUnstartedDaemon(): void { - this.stopEndpointOwnershipWatch() - this.cancelInitialAdoptionTimer() - this.unlinkOwnedEndpointArtifacts() - } - - 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 async finishRpcShutdown(serverClose: Promise): Promise { - await this.finishOrdinaryShutdown(serverClose) - this.onRpcShutdown() - } - - private unlinkOwnedEndpointArtifacts(): void { - // Ownership-checked so a late replacement's token or PID record is never removed. - unlinkOwnedDaemonTokenFile(this.tokenPath, this.token) - if (this.pidPath && this.launchNonce) { - unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) - } - // The endpoint is deliberately left: fencing its removal against a replacement that published - // meanwhile was this component's largest source of defects, and a dead entry costs nothing. - this.ownedSocketIdentity = null - } - - private startEndpointOwnershipWatch(): void { - if (process.platform === 'win32' || !this.ownedSocketIdentity) { - return - } - this.endpointOwnershipTimer = setInterval( - () => this.checkEndpointOwnership(), - DaemonServer.ENDPOINT_OWNERSHIP_POLL_MS - ) - // A liveness poll must never be the reason the process cannot exit. - this.endpointOwnershipTimer.unref() - } - - private stopEndpointOwnershipWatch(): void { - if (this.endpointOwnershipTimer === null) { - return - } - clearInterval(this.endpointOwnershipTimer) - this.endpointOwnershipTimer = null - } - - /** - * Retires a daemon that no longer owns the canonical endpoint. - * - * Such a daemon keeps hosting PTYs no client can reach — terminals that take input and never - * run it. Retirement drains rather than kills: sessions finish and the process exits once idle. - */ - private checkEndpointOwnership(): void { - if (process.platform === 'win32' || !this.ownedSocketIdentity || this.shutdownPromise) { - return - } - // An inconclusive stat resets too: EACCES must not retire a daemon that is still serving. - if (this.observeEndpointOwnership() !== 'lost') { - return - } - this.endpointOwnershipLossStreak++ - // Two, though a single rename leaves no gap to misread: this is a backstop, not the detector. - if (this.endpointOwnershipLossStreak < DaemonServer.ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS) { - return - } - this.requestRetirementForLostEndpoint() - } - - /** - * Whether the endpoint demonstrably no longer resolves to this daemon. Only positive evidence - * counts — treating an unreadable stat as loss would refuse sessions on a healthy daemon. - */ - private hasLostEndpointOwnership(): boolean { - if (this.endpointOwnershipLost) { + if (this.endpoint.lost) { return true } - return this.observeEndpointOwnership() === 'lost' + return this.connections.transportCount === 0 && this.connections.size === 0 } - /** - * Reads ownership and keeps the watchdog's loss streak honest. Both callers come through here - * because the watchdog retires on *consecutive* losses: a read that skipped the streak let two - * losses separated by an owned observation count as consecutive, poisoning a healthy daemon. - */ - private observeEndpointOwnership(): DaemonEndpointOwnershipState { - // The running check, not just `shutdownPromise`: both shutdown routes close the server before - // assigning it, and in that window the listener is gone while the recorded identity is not. - if ( - process.platform === 'win32' || - !this.ownedSocketIdentity || - this.idleShutdownState !== 'running' - ) { - return 'indeterminate' - } - const state = readDaemonEndpointOwnershipState(this.socketPath, this.ownedSocketIdentity) - if (state !== 'lost') { - this.endpointOwnershipLossStreak = 0 - } - return state - } - - private requestRetirementForLostEndpoint(): void { - // Recorded before any dedup and not gated on retirement: folding the two together left the - // loss unrecorded when already retiring, and a later hello returned the daemon to service. - const alreadyLost = this.endpointOwnershipLost - this.endpointOwnershipLost = true - this.ownedSocketIdentity = null - if (!alreadyLost) { - this.log.log('endpoint-ownership-lost', { socketPath: this.socketPath }) - console.warn( - '[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end' - ) - } - this.retirementRequested = true - // The identity it compares against was just cleared, so every later tick early-returns. - this.stopEndpointOwnershipWatch() - this.reevaluateIdleShutdown() - } - - private async disposeDaemonResources(): Promise { - this.stopEndpointOwnershipWatch() + private async disposeResources(): Promise { + this.endpoint.stopOwnershipWatch() this.stopStreamBacklogProbe() this.transientFactRelay.dispose() - this.cancelAllPendingPtySpawnPreparations() + this.preparations.cancelAll() try { await this.host.dispose() - } catch (err) { - // Why: an unreapable child must not block daemon exit — post-exit it reparents to init anyway. + } catch (error) { this.log.log('shutdown-dispose-failed', { - error: err instanceof Error ? err.message : String(err) + error: error instanceof Error ? error.message : String(error) }) } this.streamDataBatcher.clear() this.historySeedTransfers.dispose() - 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) => { - // Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof. - server.close(() => { - // Why: libuv unlinks the path this server bound, which is our private bind name and is - // already gone. Nothing removes the canonical endpoint — a departing daemon leaves it - // for the next publisher to replace — so closing late cannot delete a replacement's. - resolve() - }) - }) - } - - private isIdle(): boolean { - if (this.createOrAttachInFlight > 0 || this.host.listSessions().length > 0) { - return false - } - // Why open connections stop counting once the endpoint is lost: they belong to clients that - // reached this daemon before the takeover, and holding them open cannot make it reachable to - // anyone new. Waiting for them turns a drained retirement into an orphan that outlives its - // usefulness — the sessions are gone and nothing can route a new one here. - if (this.endpointOwnershipLost) { - return true - } - return this.transportSockets.size === 0 && this.clients.size === 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; clear pending state to keep it usable. - this.idleShutdownState = 'running' - this.reevaluateIdleShutdown() - return - } - - this.idleShutdownState = 'shutting-down' - // beginServerClose() runs synchronously up to server.close() before any 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: a connection accepted just before close() gets an explicit retry signal instead of dying mid-auth. - socket.end( - encodeNdjson({ - type: 'hello', - ok: false, - error: 'Daemon temporarily unavailable; reconnect', - retryable: true - }) - ) - return - } - // Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing. - const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser( - (msg) => this.handleFirstMessage(socket, msg, parser), - () => { - socket.destroy() - } - ) - - socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) - } - - private handleFirstMessage( - socket: Socket, - msg: unknown, - _parser: ReturnType - ): void { - const hello = msg as HelloMessage - if (hello.type !== 'hello') { - this.log.log('client-hello-rejected', { reason: 'expected-hello' }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) - socket.destroy() - return - } - - if (hello.version !== this.protocolVersion) { - this.log.log('client-hello-rejected', { - reason: 'protocol-mismatch', - clientVersion: hello.version - }) - socket.write( - encodeNdjson({ - type: 'hello', - ok: false, - error: 'Protocol version mismatch' - }) - ) - socket.destroy() - return - } - - if (hello.token !== this.token) { - this.log.log('client-hello-rejected', { - reason: 'invalid-token', - role: hello.role - }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) - socket.destroy() - return - } - - this.log.log('client-hello-accepted', { - role: hello.role, - clientId: hello.clientId - }) - socket.write( - encodeNdjson({ - type: 'hello', - ok: true, - ...(this.launchNonce && this.startedAtMs - ? { - daemonIdentity: { - pid: process.pid, - startedAtMs: this.startedAtMs, - launchNonce: this.launchNonce, - ...(this.entryPath ? { entryPath: this.entryPath } : {}), - ...(this.appVersion ? { appVersion: this.appVersion } : {}), - ...(this.spawnerExecPath ? { spawnerExecPath: this.spawnerExecPath } : {}) - } - } - : {}) - }) - ) - - if (hello.role === 'control') { - const previous = this.clients.get(hello.clientId) - const client: ConnectedClient = { - clientId: hello.clientId, - controlSocket: socket, - streamSocket: null, - authenticatedPairEstablished: false - } - this.clients.set(hello.clientId, client) - this.setupControlSocket(socket, hello.clientId) - if (previous) { - // Why: reconnect reuses clientId before stale close fires; cancel the old owner's preflight at handoff. - this.cancelPendingPtySpawnPreparationsForClient(hello.clientId) - this.historySeedTransfers.clearOwner(hello.clientId) - this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) - // Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement. - previous.streamSocket?.destroy() - previous.controlSocket.destroy() - } - } else if (hello.role === 'stream') { - const client = this.clients.get(hello.clientId) - if (!client) { - // Why: a stream socket is meaningless without its control socket; drop the orphan. - socket.destroy() - return - } - this.setupStreamSocket(socket, client) - client.authenticatedPairEstablished = true - // Why: one-shot health probes authenticate only a control socket; they are not fresh app activity. - this.onAuthenticatedClientPair() - // A complete app connection (unlike a probe) re-owns the endpoint and cancels pending - // retirement — but not a retirement caused by losing the endpoint itself. A client that - // connected before the takeover cannot make this daemon reachable again, and treating it - // as re-ownership would reopen session creation on a daemon nothing can find. - this.initialAdoptionDeadlineMs = null - this.cancelInitialAdoptionTimer() - if (!this.endpointOwnershipLost) { - this.retirementRequested = false - } - } - } - - private setupControlSocket(socket: Socket, clientId: string): void { - // Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted. - const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser( - (msg) => this.handleRequest(socket, clientId, msg as DaemonRequest), - () => {} // Ignore parse errors - ) - - // Remove the initial data listener and replace with the RPC parser - socket.removeAllListeners('data') - socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) - - socket.on('close', () => { - const client = this.clients.get(clientId) - if (client?.controlSocket !== socket) { - return - } - // Why: a client that disconnects mid-preflight would otherwise still create - // its daemon PTY, orphaning a durable, unattached session — cancel its preps (F4). - this.cancelPendingPtySpawnPreparationsForClient(clientId) - this.historySeedTransfers.clearOwner(clientId) - const wasFullyAuthenticated = client.authenticatedPairEstablished - this.streamDataBatcher.clear(clientId) - this.detachClientSessions(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, incomplete transports may block retirement but never erase it. - this.retirementRequested = true - } - - private setupStreamSocket(socket: Socket, client: ConnectedClient): void { - const previous = client.streamSocket - socket.removeAllListeners('data') - client.streamSocket = socket - // Why: 'drain' is the wake-up for the batcher's shallow-gate held bulk. - socket.on('drain', () => { - this.streamDataBatcher.flush(client.clientId) - }) - - const cleanup = (): void => { - socket.removeListener('close', cleanup) - socket.removeListener('error', cleanup) - if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) { - return - } - // Why: a preflight that outlives its output channel would create an unattached daemon PTY. - this.cancelPendingPtySpawnPreparationsForClient(client.clientId) - this.streamDataBatcher.clear(client.clientId) - this.detachClientSessions(client.clientId) - client.streamSocket = null - } - - socket.on('close', cleanup) - socket.on('error', cleanup) - - if (previous && previous !== socket) { - // Why: replacing a stream socket must not leave the old channel alive and untracked. - previous.destroy() - } + // Let canceled in-flight RPCs enqueue their protocol errors before transport destruction. + await waitForImmediate() + this.connections.dispose() } private async handleRequest( @@ -857,660 +258,23 @@ export class DaemonServer { request: DaemonRequest ): Promise { const isNotify = request.id.startsWith(NOTIFY_PREFIX) - try { - const result = await this.routeRequest(clientId, request) + const result = await this.requestRouter.route(clientId, request) if (!isNotify) { - const pendingShutdown = this.pendingShutdownReplies.get( - this.shutdownReplyKey(clientId, request.id) - ) socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => { - pendingShutdown?.start() + this.lifecycle.startPendingShutdownReply(clientId, request.id) }) } - } catch (err) { + } catch (error) { if (!isNotify) { socket.write( encodeNdjson({ id: request.id, ok: false, - error: err instanceof Error ? err.message : String(err) + error: error instanceof Error ? error.message : String(error) }) ) } } } - - 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 peer must not pin a fenced daemon by holding its ack 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 }) - } - - /** - * Registers a cancellable preparation without doing spawn preflight. Every - * createOrAttach registers one, including attach-only: without it the server - * cannot match the client's cancel, and an attach-only request queued behind a - * hung create is bounded by neither side. - */ - private registerPtySpawnPreparation( - sessionId: string, - clientId: string, - requestId: string, - cancelAfterMs: unknown - ): PendingPtySpawnPreparation { - const preparation: PendingPtySpawnPreparation = { - canceled: false, - controller: new AbortController(), - clientId, - requestId - } - if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { - preparation.cancelTimer = setTimeout( - () => { - cancelPtySpawnPreparation(preparation) - }, - Math.min(Number(cancelAfterMs), 300_000) - ) - preparation.cancelTimer.unref() - } - const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set() - pending.add(preparation) - this.pendingPtySpawnPreparations.set(sessionId, pending) - return preparation - } - - private async preparePtySpawnUnlessCanceled( - sessionId: string, - preparation: PendingPtySpawnPreparation - ): Promise { - // Why: registered before the async probe so a concurrent close can cancel this creation before a subprocess exists. - await this.preparePtySpawn() - if (preparation.canceled) { - throw new TerminalAttachCanceledError(sessionId) - } - } - - private finishPtySpawnPreparation( - sessionId: string, - preparation: PendingPtySpawnPreparation - ): void { - if (preparation.cancelTimer) { - clearTimeout(preparation.cancelTimer) - } - const pending = this.pendingPtySpawnPreparations.get(sessionId) - pending?.delete(preparation) - if (pending?.size === 0) { - this.pendingPtySpawnPreparations.delete(sessionId) - } - } - - private cancelPendingPtySpawnPreparations( - sessionId: string, - request?: { clientId: string; requestId?: string } - ): boolean { - const pending = this.pendingPtySpawnPreparations.get(sessionId) - if (!pending) { - return false - } - let canceled = false - for (const preparation of pending) { - if ( - request && - (preparation.clientId !== request.clientId || - (request.requestId !== undefined && preparation.requestId !== request.requestId)) - ) { - continue - } - cancelPtySpawnPreparation(preparation) - canceled = true - } - return canceled - } - - private cancelAllPendingPtySpawnPreparations(): void { - for (const sessionId of this.pendingPtySpawnPreparations.keys()) { - this.cancelPendingPtySpawnPreparations(sessionId) - } - } - - private cancelPendingPtySpawnPreparationsForClient(clientId: string): void { - for (const pending of this.pendingPtySpawnPreparations.values()) { - for (const preparation of pending) { - if (preparation.clientId === clientId) { - cancelPtySpawnPreparation(preparation) - } - } - } - } - - private detachClientSessions(clientId: string): void { - const attachments: { sessionId: string; token: symbol }[] = [] - for (const [sessionId, attachedClientId] of this.streamClientIdBySessionId) { - if (attachedClientId !== clientId) { - continue - } - const token = this.attachTokenBySessionId.get(sessionId) - if (token) { - attachments.push({ sessionId, token }) - } - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - } - if (attachments.length > 0) { - this.host.detachClients(attachments) - } - } - - private detachSessionForClient(sessionId: string, clientId: string): void { - if (this.streamClientIdBySessionId.get(sessionId) !== clientId) { - return - } - const token = this.attachTokenBySessionId.get(sessionId) - if (token) { - this.host.detach(sessionId, token) - } - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - } - - private async routeRequest(clientId: string, request: DaemonRequest): Promise { - const client = this.clients.get(clientId) - - switch (request.type) { - case 'startHistorySeedTransfer': { - if (!client?.authenticatedPairEstablished || client.streamSocket === null) { - throw new Error('Daemon client connection is incomplete; reconnect') - } - const transferId = this.historySeedTransfers.start(clientId, request.payload) - return { transferId } - } - - case 'appendHistorySeedTransfer': - this.historySeedTransfers.append( - clientId, - request.payload.transferId, - request.payload.index, - request.payload.data - ) - return {} - - case 'finishHistorySeedTransfer': - this.historySeedTransfers.finish(clientId, request.payload.transferId) - return {} - - case 'abortHistorySeedTransfer': - this.historySeedTransfers.abort(clientId, request.payload.transferId) - return {} - - case 'createOrAttach': { - if (this.idleShutdownState !== 'running') { - throw new Error('Daemon temporarily unavailable; reconnect') - } - if (!client?.authenticatedPairEstablished || client.streamSocket === null) { - // Why: a control-only replacement can't own terminal admission or erase the prior client's retirement request. - throw new Error('Daemon client connection is incomplete; reconnect') - } - const p = request.payload - const attachOnly = p.attachOnly === true - // Why check here and not only on the watchdog: publishing cannot be made atomic against - // a publisher preempted between proving an entry dead and replacing it, so this daemon - // can lose the endpoint at any moment. The watchdog notices within a poll, which is far - // too late if a session was accepted in between — that session is then reachable by - // nobody, and the user sees a terminal that acknowledges input and never runs it. - // Why creation only: an attach reaches a session this daemon already hosts, over a - // connection that already exists. Refusing that would break the drain a retiring daemon - // depends on, and it strands nothing — the session is already here. - if (!attachOnly && this.hasLostEndpointOwnership()) { - this.requestRetirementForLostEndpoint() - throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE) - } - this.createOrAttachInFlight++ - let routedSessionId = p.sessionId - let result: Awaited> - let spawnPreparation: PendingPtySpawnPreparation | null = null - try { - if ( - p.agentSessionEnsure !== undefined && - (!isAgentSessionExecutionClaim(p.agentSessionEnsure.claim) || - !isAgentSessionSurfaceBinding(p.agentSessionEnsure.surface)) - ) { - throw new Error('agent_session_identity_required') - } - spawnPreparation = this.registerPtySpawnPreparation( - p.sessionId, - clientId, - request.id, - p.cancelAfterMs - ) - if (!attachOnly) { - await this.preparePtySpawnUnlessCanceled(p.sessionId, spawnPreparation) - } - if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) { - throw new Error('Multiple terminal history seed sources') - } - const historySeedChunks = - p.historySeedTransferId !== undefined - ? this.historySeedTransfers.take(clientId, p.historySeedTransferId) - : p.historySeed !== undefined - ? [p.historySeed] - : undefined - 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, - ...(attachOnly ? { attachOnly: true } : {}), - // Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity. - ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), - shellOverride: p.shellOverride, - terminalWindowsWslDistro: p.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, - shellReadySupported: p.shellReadySupported, - historySeedChunks, - startupIngress: parsePtyStartupIngressIntent(p.startupIngress), - ...(p.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } - : {}), - ...(p.agentSessionEnsure ? { agentSessionEnsure: p.agentSessionEnsure } : {}), - ...(spawnPreparation - ? { - isCanceled: () => spawnPreparation?.canceled === true, - cancelSignal: spawnPreparation.controller.signal - } - : {}), - onSessionResolved: (sessionId) => { - routedSessionId = sessionId - }, - streamClient: { - onData: (data, rawLength = data.length, transformed = false, seq) => { - // Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless. - this.transientFactRelay.onSessionData(routedSessionId, data) - const lastInputAt = this.lastInputAtBySessionId.get(routedSessionId) - const isInteractiveOutput = - data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS - this.streamDataBatcher.enqueue(clientId, routedSessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS, - rawLength, - transformed, - seq - }) - }, - onExit: (code, incarnationId, cause) => { - // Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output. - this.log.log('session-exited', { - sessionId: routedSessionId, - code, - cause: cause?.kind - }) - this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'exit', - sessionId: routedSessionId, - payload: { code, incarnationId, ...(cause ? { cause } : {}) } - }) - this.streamDataBatcher.flush(clientId) - recordDaemonStreamBacklogEvent('sessionExit', { - sessionIdSuffix: routedSessionId.slice(-10) - }) - this.transientFactRelay.onSessionExit(routedSessionId) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) - this.streamClientIdBySessionId.delete(routedSessionId) - this.attachTokenBySessionId.delete(routedSessionId) - this.lastInputAtBySessionId.delete(routedSessionId) - this.reevaluateIdleShutdown() - } - } - }) - } finally { - if (spawnPreparation) { - this.finishPtySpawnPreparation(p.sessionId, spawnPreparation) - } - this.createOrAttachInFlight-- - this.reevaluateIdleShutdown() - } - routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId - if ( - this.clients.get(clientId) !== client || - !client.authenticatedPairEstablished || - client.streamSocket === null - ) { - this.host.detach(routedSessionId, result.attachToken) - throw new TerminalAttachCanceledError(routedSessionId) - } - this.streamClientIdBySessionId.set(routedSessionId, clientId) - this.attachTokenBySessionId.set(routedSessionId, result.attachToken) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) - // Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head. - if (this.transientFactRelay.isBackgrounded(routedSessionId)) { - this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'sessionBackgroundMarker', - sessionId: routedSessionId, - payload: { background: true } - }) - } - this.log.log(result.isNew ? 'session-created' : 'session-attached', { - sessionId: routedSessionId, - pid: result.pid - }) - return { - isNew: result.isNew, - snapshot: result.snapshot, - pid: result.pid, - shellState: result.shellState, - incarnationId: result.incarnationId, - ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), - wslDistro: result.wslDistro, - ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) - } - } - - case 'cancelCreateOrAttach': - return { - canceled: this.cancelPendingPtySpawnPreparations(request.payload.sessionId, { - clientId, - ...(typeof request.payload.requestId === 'string' - ? { requestId: request.payload.requestId } - : {}) - }) - } - - case 'closeStartupQueryAuthority': - return { - appliedSeq: this.host.closeStartupQueryAuthority(request.payload.sessionId) - } - - case 'write': - try { - this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now()) - this.host.write(request.payload.sessionId, request.payload.data) - } catch (err) { - this.lastInputAtBySessionId.delete(request.payload.sessionId) - if (err instanceof SessionNotFoundError) { - this.sendExitEvent(client, request.payload.sessionId, -1) - } - throw err - } - return {} - - case 'resize': - try { - this.host.resize(request.payload.sessionId, request.payload.cols, request.payload.rows) - } catch (err) { - if (err instanceof SessionNotFoundError) { - this.sendExitEvent(client, request.payload.sessionId, -1) - } - throw err - } - return {} - - case 'pausePty': - this.host.pauseProducer(request.payload.sessionId) - return {} - - case 'resumePty': - this.host.resumeProducer(request.payload.sessionId) - return {} - - case 'setSessionBackground': { - const sessionId = request.payload.sessionId - const background = request.payload.background === true - recordDaemonStreamBacklogEvent('setSessionBackground', { - sessionIdSuffix: sessionId.slice(-10), - background - }) - const backgroundChanged = this.transientFactRelay.setSessionBackground( - sessionId, - background - ) - this.streamDataBatcher.refreshSessionDroppability(sessionId) - if (!backgroundChanged) { - return {} - } - if (background) { - // Seed the fresh relay tracker with the emulator's dangling escape so a handoff-split sequence still parses. - this.transientFactRelay.seedSessionScanState( - sessionId, - this.host.getPartialEscapeTailAnsi(sessionId) - ) - } - const streamClientId = this.streamClientIdBySessionId.get(sessionId) - if (!streamClientId) { - // Not attached yet — the attach-time marker covers the handoff. - return {} - } - // Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker. - const mode2031State = this.transientFactRelay.getMode2031ReplyScanState(sessionId) - const scanSeedAnsi = background - ? '' - : mode2031State.pendingSubscribe - ? mode2031State.tail - : this.host.getPartialEscapeTailAnsi(sessionId) - this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { - type: 'event', - event: 'sessionBackgroundMarker', - sessionId, - payload: { - background, - ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}), - ...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {}) - } - }) - return {} - } - - case 'kill': { - const canceledPendingSpawn = this.cancelPendingPtySpawnPreparations( - request.payload.sessionId - ) - this.lastInputAtBySessionId.delete(request.payload.sessionId) - const attribution = { - sessionId: request.payload.sessionId, - immediate: request.payload.immediate === true, - // Daemon control identity, not the paired-device bearer credential. - clientId - } - try { - await this.host.kill(request.payload.sessionId, { - immediate: request.payload.immediate - }) - } catch (error) { - // Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done. - if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { - this.log.log('session-kill-failed', attribution) - throw error - } - } - this.log.log('session-killed', attribution) - return {} - } - - case 'signal': - this.host.signal(request.payload.sessionId, request.payload.signal) - return {} - - case 'detach': - this.detachSessionForClient(request.payload.sessionId, clientId) - this.log.log('session-detached', { - sessionId: request.payload.sessionId - }) - return {} - - case 'getCwd': - return { cwd: await this.host.getCwd(request.payload.sessionId) } - - case 'getForegroundProcess': - return { - foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) - } - - case 'inspectProcess': - return this.host.inspectProcess(request.payload.sessionId) - - case 'confirmForegroundProcess': - return { - foregroundProcess: await this.host.confirmForegroundProcess(request.payload.sessionId) - } - - case 'clearScrollback': - this.host.clearScrollback(request.payload.sessionId) - return {} - - 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 races between the empty proof and 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 - const scrollbackRows = - typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows) - ? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows))) - : undefined - const snapshot = this.host.getSnapshot(request.payload.sessionId, { - scrollbackRows - }) - const snapshotMs = performance.now() - snapshotStart - if (snapshotMs >= 25) { - // Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family). - recordDaemonStreamBacklogEvent('slowGetSnapshot', { - sessionIdSuffix: request.payload.sessionId.slice(-10), - snapshotMs: Math.round(snapshotMs) - }) - } - return { snapshot } - } - - case 'getSize': - return { size: this.host.getAppliedSize(request.payload.sessionId) } - - case 'takePendingOutput': - // Why no await: with includeSnapshot, drain+serialize must share one sync turn or cold restore replays doubled PTY bytes. - return this.host.takePendingOutput( - request.payload.sessionId, - request.payload.includeSnapshot === true, - { teardownSnapshot: request.payload.teardownSnapshot === true } - ) - - case 'ping': - return { pong: true } - - case 'systemResolverHealth': - return { health: await readCurrentProcessMacSystemResolverHealth() } - - case 'ptySpawnHealth': - await this.ptySpawnHealthCheck() - return { healthy: true } - - 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() - } catch (err) { - // Why: shutdown must always self-terminate; failed owners stay retryable for the follow-up shutdown() below. - this.log.log('shutdown-dispose-failed', { - error: err instanceof Error ? err.message : String(err) - }) - } - } - const controlSocket = this.clients.get(clientId)?.controlSocket - if (controlSocket) { - this.deferShutdownUntilReply(clientId, request.id, controlSocket, () => - this.finishRpcShutdown(serverClose) - ) - } else if (!this.shutdownPromise) { - this.shutdownPromise = this.finishRpcShutdown(serverClose) - } - return {} - } - } - throw new Error(`Unknown request type: ${(request as { type: string }).type}`) - } - - private sendExitEvent( - client: ConnectedClient | undefined, - sessionId: string, - code: number - ): void { - if (!client?.streamSocket) { - return - } - // Why: write/resize don't wait for replies, so this synthetic exit is the renderer's only signal to clear stale pane bindings. - this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { - type: 'event', - event: 'exit', - sessionId, - payload: { code } - }) - this.streamDataBatcher.flush(client.clientId) - } } diff --git a/src/main/daemon/daemon-session-attachments.ts b/src/main/daemon/daemon-session-attachments.ts new file mode 100644 index 00000000000..2a012c1c9d4 --- /dev/null +++ b/src/main/daemon/daemon-session-attachments.ts @@ -0,0 +1,67 @@ +import { performance } from 'node:perf_hooks' +import type { TerminalHost } from './terminal-host' + +export class DaemonSessionAttachments { + private readonly clientIdBySessionId = new Map() + private readonly tokenBySessionId = new Map() + private readonly lastInputAtBySessionId = new Map() + + constructor(private readonly host: TerminalHost) {} + + attach(sessionId: string, clientId: string, token: symbol): void { + this.clientIdBySessionId.set(sessionId, clientId) + this.tokenBySessionId.set(sessionId, token) + } + + clientIdForSession(sessionId: string): string | undefined { + return this.clientIdBySessionId.get(sessionId) + } + + recordInput(sessionId: string): void { + this.lastInputAtBySessionId.set(sessionId, performance.now()) + } + + lastInputAt(sessionId: string): number | undefined { + return this.lastInputAtBySessionId.get(sessionId) + } + + clearInput(sessionId: string): void { + this.lastInputAtBySessionId.delete(sessionId) + } + + release(sessionId: string): void { + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + this.lastInputAtBySessionId.delete(sessionId) + } + + detachSessionForClient(sessionId: string, clientId: string): void { + if (this.clientIdBySessionId.get(sessionId) !== clientId) { + return + } + const token = this.tokenBySessionId.get(sessionId) + if (token) { + this.host.detach(sessionId, token) + } + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + } + + detachClientSessions(clientId: string): void { + const attachments: { sessionId: string; token: symbol }[] = [] + for (const [sessionId, attachedClientId] of this.clientIdBySessionId) { + if (attachedClientId !== clientId) { + continue + } + const token = this.tokenBySessionId.get(sessionId) + if (token) { + attachments.push({ sessionId, token }) + } + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + } + if (attachments.length > 0) { + this.host.detachClients(attachments) + } + } +} diff --git a/src/main/daemon/daemon-session-background-routing.ts b/src/main/daemon/daemon-session-background-routing.ts new file mode 100644 index 00000000000..f3e45ba7abc --- /dev/null +++ b/src/main/daemon/daemon-session-background-routing.ts @@ -0,0 +1,55 @@ +import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { TerminalHost } from './terminal-host' + +type DaemonSessionBackgroundRoutingOptions = { + host: TerminalHost + attachments: DaemonSessionAttachments + transientFactRelay: BackgroundTransientFactRelay + streamDataBatcher: DaemonStreamDataBatcher +} + +export class DaemonSessionBackgroundRouting { + constructor(private readonly options: DaemonSessionBackgroundRoutingOptions) {} + + setBackground(sessionId: string, background: boolean): Record { + recordDaemonStreamBacklogEvent('setSessionBackground', { + sessionIdSuffix: sessionId.slice(-10), + background + }) + const changed = this.options.transientFactRelay.setSessionBackground(sessionId, background) + this.options.streamDataBatcher.refreshSessionDroppability(sessionId) + if (!changed) { + return {} + } + if (background) { + this.options.transientFactRelay.seedSessionScanState( + sessionId, + this.options.host.getPartialEscapeTailAnsi(sessionId) + ) + } + const streamClientId = this.options.attachments.clientIdForSession(sessionId) + if (!streamClientId) { + return {} + } + const mode2031State = this.options.transientFactRelay.getMode2031ReplyScanState(sessionId) + const scanSeedAnsi = background + ? '' + : mode2031State.pendingSubscribe + ? mode2031State.tail + : this.options.host.getPartialEscapeTailAnsi(sessionId) + this.options.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId, + payload: { + background, + ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}), + ...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {}) + } + }) + return {} + } +} diff --git a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts index 1f1f0dce8d2..64ae0f77ba0 100644 --- a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts +++ b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DaemonPtyAdapter, FinalCheckpointWaitExpiredError } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' import type { DaemonFileLog } from './daemon-file-log' diff --git a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts index f62cec1179c..fda1fe25b98 100644 --- a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts +++ b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts @@ -4,6 +4,7 @@ import type { Socket } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' @@ -16,22 +17,14 @@ type MockSubprocess = SubprocessHandle & { } type DaemonLifecyclePrivate = { - clients: Map< - string, - { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean - } - > - host: { - getPartialEscapeTailAnsi(sessionId: string): string + connections: { clients: Map } + host: { getPartialEscapeTailAnsi(sessionId: string): string } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise } - routeRequest(clientId: string, request: DaemonRequest): Promise streamDataBatcher: DaemonStreamDataBatcher transientFactRelay: BackgroundTransientFactRelay - streamClientIdBySessionId: Map + attachments: { clientIdBySessionId: Map } } function createMockSubprocess(): MockSubprocess { @@ -95,7 +88,7 @@ function addClient( write: ReturnType writableLength: number } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, @@ -134,7 +127,7 @@ describe('daemon stream droppability lifecycle', () => { server = harness.server const { daemon } = harness addClient(daemon) - daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') + daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1') vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('') const lifecycle: string[] = [] vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation( @@ -152,7 +145,7 @@ describe('daemon stream droppability lifecycle', () => { } ) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'background', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: true } @@ -160,7 +153,7 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:true', 'marker:true']) lifecycle.length = 0 - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'duplicate-background', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: true } @@ -168,7 +161,7 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:true']) lifecycle.length = 0 - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'foreground', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: false } @@ -181,13 +174,13 @@ describe('daemon stream droppability lifecycle', () => { server = harness.server const { daemon } = harness addClient(daemon) - daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') + daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1') daemon.transientFactRelay.setSessionBackground('session-toggle', true) daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?') vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?') const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent') - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'foreground', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: false } @@ -216,7 +209,9 @@ describe('daemon stream droppability lifecycle', () => { const lifecycle: string[] = [] vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation( (sessionId) => { - lifecycle.push(`refresh:${daemon.streamClientIdBySessionId.get(sessionId) ?? 'unrouted'}`) + lifecycle.push( + `refresh:${daemon.attachments.clientIdBySessionId.get(sessionId) ?? 'unrouted'}` + ) } ) vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent').mockImplementation( @@ -225,7 +220,7 @@ describe('daemon stream droppability lifecycle', () => { } ) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'attach', type: 'createOrAttach', payload: { sessionId: 'session-attach', cols: 80, rows: 24 } @@ -241,7 +236,7 @@ describe('daemon stream droppability lifecycle', () => { addClient(daemon, 128 * 1024) daemon.transientFactRelay.setSessionBackground('session-exit', true) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'attach', type: 'createOrAttach', payload: { sessionId: 'session-exit', cols: 80, rows: 24 } @@ -262,6 +257,6 @@ describe('daemon stream droppability lifecycle', () => { payload: { code: 42 } }) expect(daemon.transientFactRelay.isBackgrounded('session-exit')).toBe(false) - expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('session-exit')).toBe(false) }) }) diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts new file mode 100644 index 00000000000..b47b497fe43 --- /dev/null +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -0,0 +1,213 @@ +import { performance } from 'node:perf_hooks' +import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonClientConnections } from './daemon-client-connections' +import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' +import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import type { DaemonFileLog } from './daemon-file-log' +import type { + DaemonPtySpawnPreparations, + PendingPtySpawnPreparation +} from './daemon-pty-spawn-preparations' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import { + isAgentSessionExecutionClaim, + isAgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' +import { type DaemonRequest, TerminalAttachCanceledError } from './types' + +type CreateOrAttachRequest = Extract + +type DaemonTerminalAdmissionOptions = { + host: TerminalHost + connections: DaemonClientConnections + endpoint: DaemonEndpointLifecycle + preparations: DaemonPtySpawnPreparations + attachments: DaemonSessionAttachments + historySeedTransfers: TerminalHistorySeedTransferRegistry + transientFactRelay: BackgroundTransientFactRelay + streamDataBatcher: DaemonStreamDataBatcher + log: DaemonFileLog + isAcceptingWork: () => boolean + requestEndpointRetirement: () => void + reevaluateIdleShutdown: () => void +} + +export class DaemonTerminalAdmission { + private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100 + private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + private createOrAttachInFlight = 0 + + constructor(private readonly options: DaemonTerminalAdmissionOptions) {} + + get inFlight(): number { + return this.createOrAttachInFlight + } + + async createOrAttach(clientId: string, request: CreateOrAttachRequest): Promise { + const client = this.options.connections.get(clientId) + if (!this.options.isAcceptingWork()) { + throw new Error('Daemon temporarily unavailable; reconnect') + } + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + throw new Error('Daemon client connection is incomplete; reconnect') + } + const payload = request.payload + const attachOnly = payload.attachOnly === true + if (!attachOnly && this.options.endpoint.hasLostOwnership()) { + this.options.requestEndpointRetirement() + throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE) + } + this.createOrAttachInFlight++ + let routedSessionId = payload.sessionId + let result: CreateOrAttachResult + let spawnPreparation: PendingPtySpawnPreparation | null = null + try { + if ( + payload.agentSessionEnsure !== undefined && + (!isAgentSessionExecutionClaim(payload.agentSessionEnsure.claim) || + !isAgentSessionSurfaceBinding(payload.agentSessionEnsure.surface)) + ) { + throw new Error('agent_session_identity_required') + } + spawnPreparation = this.options.preparations.register( + payload.sessionId, + clientId, + request.id, + payload.cancelAfterMs + ) + if (!attachOnly) { + await this.options.preparations.prepareUnlessCanceled(payload.sessionId, spawnPreparation) + } + if (payload.historySeed !== undefined && payload.historySeedTransferId !== undefined) { + throw new Error('Multiple terminal history seed sources') + } + const historySeedChunks = + payload.historySeedTransferId !== undefined + ? this.options.historySeedTransfers.take(clientId, payload.historySeedTransferId) + : payload.historySeed !== undefined + ? [payload.historySeed] + : undefined + result = await this.options.host.createOrAttach({ + sessionId: payload.sessionId, + cols: payload.cols, + rows: payload.rows, + cwd: payload.cwd, + env: payload.env, + envToDelete: payload.envToDelete, + command: payload.command, + startupCommandDelivery: payload.startupCommandDelivery, + ...(attachOnly ? { attachOnly: true } : {}), + ...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}), + shellOverride: payload.shellOverride, + terminalWindowsWslDistro: payload.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation, + shellReadySupported: payload.shellReadySupported, + historySeedChunks, + startupIngress: parsePtyStartupIngressIntent(payload.startupIngress), + ...(payload.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs } + : {}), + ...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}), + isCanceled: () => spawnPreparation?.canceled === true, + cancelSignal: spawnPreparation.controller.signal, + onSessionResolved: (sessionId) => { + routedSessionId = sessionId + }, + streamClient: this.createStreamClient(clientId, () => routedSessionId) + }) + } finally { + if (spawnPreparation) { + this.options.preparations.finish(payload.sessionId, spawnPreparation) + } + this.createOrAttachInFlight-- + this.options.reevaluateIdleShutdown() + } + + routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? payload.sessionId + if ( + this.options.connections.get(clientId) !== client || + !client.authenticatedPairEstablished || + client.streamSocket === null + ) { + this.options.host.detach(routedSessionId, result.attachToken) + throw new TerminalAttachCanceledError(routedSessionId) + } + this.options.attachments.attach(routedSessionId, clientId, result.attachToken) + this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId) + if (this.options.transientFactRelay.isBackgrounded(routedSessionId)) { + this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: routedSessionId, + payload: { background: true } + }) + } + this.options.log.log(result.isNew ? 'session-created' : 'session-attached', { + sessionId: routedSessionId, + pid: result.pid + }) + return { + isNew: result.isNew, + snapshot: result.snapshot, + pid: result.pid, + shellState: result.shellState, + incarnationId: result.incarnationId, + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + wslDistro: result.wslDistro, + ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + } + } + + private createStreamClient( + clientId: string, + sessionId: () => string + ): CreateOrAttachOptions['streamClient'] { + return { + onData: (data, rawLength = data.length, transformed = false, seq) => { + const routedSessionId = sessionId() + this.options.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) + const isInteractiveOutput = + data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS + this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq + }) + }, + onExit: (code, incarnationId, cause) => { + const routedSessionId = sessionId() + this.options.log.log('session-exited', { + sessionId: routedSessionId, + code, + cause: cause?.kind + }) + this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'exit', + sessionId: routedSessionId, + payload: { code, incarnationId, ...(cause ? { cause } : {}) } + }) + this.options.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: routedSessionId.slice(-10) + }) + this.options.transientFactRelay.onSessionExit(routedSessionId) + this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId) + this.options.attachments.release(routedSessionId) + this.options.reevaluateIdleShutdown() + } + } + } +} diff --git a/src/main/daemon/pty-subprocess-test-harness.ts b/src/main/daemon/pty-subprocess-test-harness.ts index 7113af73ef8..623dd20bf63 100644 --- a/src/main/daemon/pty-subprocess-test-harness.ts +++ b/src/main/daemon/pty-subprocess-test-harness.ts @@ -11,7 +11,10 @@ const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_MIMOCODE_HOME', 'ORCA_PI_CODING_AGENT_DIR', 'ORCA_OMP_CODING_AGENT_DIR', - 'ORCA_CODEX_HOME' + 'ORCA_OMP_STATUS_EXTENSION', + 'ORCA_CODEX_HOME', + 'ORCA_AGENT_TEAMS_SHIM_DIR', + 'ORCA_REMOTE_CLI_BIN_DIR' ] as const export const POWERLEVEL10K_WIZARD_DISABLE_ENV = 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 2f983e4f7c5..8e8d2314783 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,128 +1,20 @@ -/* eslint-disable max-lines -- Why: daemon PTY spawning must keep platform launch setup, preflight, and lifecycle guards in one execution path. */ -import * as pty from 'node-pty' -import { statSync } from 'node:fs' -import { release } from 'node:os' -import { delimiter, win32 as pathWin32 } from 'node:path' import type { SubprocessHandle } from './session-subprocess-handle' -import { DaemonProtocolError } from './types' -import { getShellLaunchConfig, resolvePtyShellPath } from './shell-ready' -import { selectShellStartupFeatures } from '../shell-startup-features' -import { isValidPtySize, normalizePtySize } from './daemon-pty-size' -import { - ensureNodePtySpawnHelperExecutable, - getNodePtySpawnHelperCandidates, - resolveUnixShellPath, - validateWorkingDirectoryAsync, - WorkingDirectoryValidationAbortedError -} from '../providers/local-pty-utils' -import { - hostReportsChildExitStatus, - wrapShellSpawnForMacosTccAttribution -} from '../providers/macos-tcc-login-shell' -import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' -import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' -import { readPtsName } from '../pty/node-pty-pts-name' -import { - ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, - resolveWindowsShellLaunchArgs -} from '../providers/windows-shell-args' -import { - resolveEffectiveWindowsPowerShell, - shouldProbeWindowsPowerShellAvailability, - type WindowsPowerShellShellFamily -} from '../providers/windows-powershell' -import { - buildWindowsPowerShellSpawnAttempts, - type WindowsShellSpawnAttempt -} from '../providers/windows-shell-fallback-chain' -import { isPwshAvailable } from '../pwsh' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' -import { removeInheritedNoColor } from '../pty/terminal-color-env' -import { dropInheritedOrcaFishHistory } from '../fish-history-session' -import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' -import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' -import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' -import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' -import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' -import { resolvePathEnvKey } from '../pty/windows-environment-path' -import { parseWslPath } from '../wsl' -import { addWslEnvKeys } from '../wsl-env' -import { - gitCredentialPromptGuardEnv, - mergeGitConfigEnvProtocol -} from '../../shared/git-credential-prompt-env' -import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard' -import { resolveWslSessionContext } from './wsl-session-context' -import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' -import { - POWERLEVEL10K_WIZARD_DISABLE_ENV, - seedPowerlevel10kWizardEnv -} from '../pty/powerlevel10k-wizard-env' -import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash' -import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcessWithAvailability } from '../providers/agent-foreground-process' -import { readWindowsConptyProcessIds } from '../providers/windows-conpty-process-membership' -import { assignHostProcessToKillOnCloseJob, terminatePtyJob } from '../windows/windows-pty-job' -import { - isAgentForegroundWrapperProcess, - recognizeAgentProcess, - recognizeAgentProcessFromCommandLine -} from '../../shared/agent-process-recognition' -import { shouldInspectOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' -import { - shouldUseShellReadyStartupDelivery, - type StartupCommandDelivery -} from '../../shared/codex-startup-delivery' -import { isShellProcess } from '../../shared/shell-process-detection' +import { normalizePtySize } from './daemon-pty-size' import { TerminalAttachCanceledError } from './daemon-errors' -import { parsePtySessionId } from './pty-session-id' -import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' -import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' -import type { TuiAgent } from '../../shared/tui-agent' +import { createDaemonPtyEnvironment } from './pty-subprocess/spawn-environment' +import { createPtyShellLaunchPlan } from './pty-subprocess/shell-launch-plan' +import { spawnNativeDaemonPty, type SpawnedDaemonPty } from './pty-subprocess/native-pty-spawn' import { - expandWindowsEnvironmentVariables, - expandWindowsPathEnvironmentVariables -} from '../../shared/windows-environment-expansion' -import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' -import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' + formatPtySpawnError, + preflightPtySpawn, + preflightPtySpawnHealth, + runPtySpawnHealthProbe +} from './pty-subprocess/spawn-preflight' +import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/tui-agent' -const PANE_IDENTITY_ENV_KEYS = [ - 'ORCA_PANE_KEY', - 'ORCA_TAB_ID', - 'ORCA_WORKTREE_ID', - 'ORCA_AGENT_LAUNCH_TOKEN' -] as const -const WINDOWS_PATH_ENV_KEY_RE = /^path$/i -const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 -const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 -// Why: a Windows refresh forks a heavy powershell.exe CIM scan (~10-40x POSIX `ps`); idle shells retry slower, output re-arms the fast retry. -const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000 -const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000 -const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 -const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 -// Why: retry once so a transient slow spawn doesn't route every terminal to the local fallback, losing daemon persistence. const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2 -const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 - -function shouldInspectOuterWrapperFallback(processName: string | null): boolean { - const recognized = recognizeAgentProcess(processName) - return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized) -} - -function composeGuardedDaemonGitConfigEnv( - env: Record, - explicitEnv: Record | undefined, - launchAgent: TuiAgent | undefined -): void { - const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] - delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] - if (policy !== 'guard' && launchAgent === undefined) { - return - } - // Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard. - Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform)) -} export type PtySubprocessOptions = { sessionId: string @@ -134,9 +26,7 @@ export type PtySubprocessOptions = { command?: string startupCommandDelivery?: StartupCommandDelivery launchAgent?: TuiAgent - /** Explicit shell executable path/basename the renderer asked for. - * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user - * who picks "New WSL terminal" from the "+" menu actually gets WSL. */ + /** Explicit shell executable path/basename requested by the renderer. */ shellOverride?: string terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' @@ -146,391 +36,21 @@ export type PtySubprocessOptions = { onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void } -function deleteRequestedDaemonEnvKeys( - env: Record, - keys: readonly string[] | undefined -): void { - // Why: the persistent daemon's inherited env can differ from Electron's. - // Compare ownership here so real-home routing neither leaks an Orca overlay - // nor deletes a user-owned CODEX_HOME chosen by the daemon's host context. - const deleteOrcaOwnedCodexHome = - keys?.includes('ORCA_CODEX_HOME') === true && - env.ORCA_CODEX_HOME !== undefined && - env.CODEX_HOME === env.ORCA_CODEX_HOME - for (const key of keys ?? []) { - delete env[key] - } - if (deleteOrcaOwnedCodexHome) { - delete env.CODEX_HOME - } -} - -/** - * Returns a stable default working directory for daemon-spawned PTYs. - */ -function getDefaultCwd(): string { - return resolveSafePtyDefaultCwd() -} - -/** - * Removes pane identity inherited from the daemon parent unless explicitly set. - */ -function removeUnspecifiedPaneIdentityEnv( - env: Record, - explicitEnv: Record | undefined -): void { - for (const key of PANE_IDENTITY_ENV_KEYS) { - if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { - delete env[key] - } - } -} - -/** Removes the second PATH key only when the daemon's env merge created it. */ -function collapseWindowsPathEnvKeys( - env: Record, - requestedEnv: Record | undefined -): void { - if (process.platform !== 'win32') { - return - } - const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) - if (pathKeys.length < 2) { - return - } - // Why: a one-key main patch is authoritative; zero or two keys came from inherited state. - const requestedKeys = requestedEnv - ? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) - : [] - if (requestedKeys.length !== 1) { - return - } - const survivingKey = requestedKeys[0] - if (!survivingKey || env[survivingKey] === undefined) { - return - } - for (const key of pathKeys) { - if (key !== survivingKey) { - delete env[key] - } - } -} - -/** - * Promotes the agent-teams shim path ahead of inherited PATH entries. - */ -function promoteAgentTeamsShimPath( - env: Record, - requestedPath: string | undefined -): void { - if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { - return - } - const normalizedRequestedPath = - process.platform === 'win32' - ? expandWindowsEnvironmentVariables(requestedPath, env) - : requestedPath - const pathDelimiter = process.platform === 'win32' ? ';' : delimiter - const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] - if (!shimDir) { - return - } - const pathKey = resolvePathEnvKey(env, process.platform) - const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] - env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) -} - -/** - * Removes stale development hook endpoints inherited by daemon children. - */ -function removeInheritedDevAgentHookEndpoint( - env: Record, - explicitEnv: Record | undefined -): void { - if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) { - // Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec. - delete env.ORCA_AGENT_HOOK_ENDPOINT - } -} - -/** - * Strips Electron's internal run-as-node flag from user shell environments. - */ -function removeInheritedElectronRunAsNode(env: Record): void { - // Why: user shells must not inherit ELECTRON_RUN_AS_NODE or nested Electron commands run as plain Node. - delete env.ELECTRON_RUN_AS_NODE -} - -function daemonEnvironmentDiagSuffix(): string { - const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' - const systemVersion = - (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || - release() - const platform = `${process.platform} ${systemVersion}` - return ` (orca: ${orca}, arch: ${process.arch}, platform: ${platform})` -} - -/** - * Formats a daemon preflight failure with the same ENOENT details node-pty exposes. - */ -function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { - const detailName = kind === 'helper' ? 'helper' : 'cwd' - const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' - const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' - const diag = daemonEnvironmentDiagSuffix() - return new DaemonProtocolError( - `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${diag}` - ) -} - -/** - * Checks whether a path currently exists and is a directory. - */ -function isExistingDirectory(path: string | undefined): path is string { - if (!path) { - return false - } - try { - return statSync(path).isDirectory() - } catch { - return false - } -} - -/** - * Moves the daemon process to a stable cwd after its original cwd disappears. - */ -function repairDaemonCwd(): string | null { - const candidates = [process.env.ORCA_USER_DATA_PATH] - try { - candidates.push(getDefaultCwd()) - } catch { - // Keep daemon cwd repair best-effort even when no user terminal cwd is safe. - } - candidates.push(process.platform === 'win32' ? 'C:\\' : '/') - for (const candidate of candidates) { - if (isExistingDirectory(candidate)) { - try { - process.chdir(candidate) - return candidate - } catch { - // Try the next stable cwd candidate. - } - } - } - return null -} - -/** - * Ensures the daemon cwd is valid before native PTY spawning. - */ -function preflightDaemonCwd(): void { - let daemonCwd = '' - try { - daemonCwd = process.cwd() - if (isExistingDirectory(daemonCwd)) { - return - } - } catch { - // Recover below; process.cwd() throws after the original cwd is deleted. - } - - // Why: if the daemon's launch worktree disappears, node-pty's macOS spawn-helper fails even when the terminal cwd is valid. - if (repairDaemonCwd()) { - return - } - throw formatMissingDaemonPathError('cwd', daemonCwd) -} - -/** - * Validates macOS node-pty helper availability before spawning terminals. - */ -function preflightMacNodePtySpawnEnvironment(): void { - if (process.platform !== 'darwin') { - return - } - - let candidates: string[] - try { - candidates = getNodePtySpawnHelperCandidates() - } catch { - throw formatMissingDaemonPathError('helper', '') - } - - for (const candidate of candidates) { - try { - if (statSync(candidate).isFile()) { - return - } - } catch { - // Try the next node-pty native location. - } - } - - throw formatMissingDaemonPathError('helper', candidates[0] ?? '') -} - -/** - * Ensures POSIX daemon-owned native PTY spawn prerequisites are still valid. - */ -function preflightUnixPtySpawnEnvironment(): void { - if (process.platform === 'win32') { - return - } - - // Why: detached daemons can outlive their launch cwd; repair before every spawn so Linux/macOS don't wait for health recovery. - preflightDaemonCwd() - preflightMacNodePtySpawnEnvironment() -} - -/** - * Detects native Windows paths that should be validated before spawn. - */ -function isNativeWindowsPath(path: string): boolean { - return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') -} - -/** - * Validates explicit native Windows cwd paths before ConPTY launch. - */ -async function preflightWindowsPtySpawnEnvironment(args: { - validationCwd: string - cwdWasExplicit: boolean - signal?: AbortSignal -}): Promise { - if (process.platform !== 'win32' || !args.cwdWasExplicit) { - return - } - - if (!isNativeWindowsPath(args.validationCwd)) { - return - } - - await validateWorkingDirectoryAsync( - args.validationCwd, - args.signal ? { signal: args.signal } : {} - ) -} - -/** - * Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT. - */ -async function preflightPosixPtySpawnEnvironment( - validationCwd: string, - signal?: AbortSignal -): Promise { - if (process.platform === 'win32') { - return - } - await validateWorkingDirectoryAsync(validationCwd, signal ? { signal } : {}) -} - -/** - * Wraps native PTY spawn failures with shell and cwd context. - */ -function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { - const message = err instanceof Error ? err.message : String(err) - const diag = daemonEnvironmentDiagSuffix() - const formatted = new DaemonProtocolError( - `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${diag}` - ) - if (err instanceof Error && err.stack) { - formatted.stack = err.stack - } - return formatted -} - -/** - * Runs one short native PTY spawn probe (spawn `/bin/sh -c 'exit 0'`). - */ -function runSinglePtySpawnHealthProbe(): Promise { - const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) - ? process.env.ORCA_USER_DATA_PATH - : getDefaultCwd() - - let proc: pty.IPty - try { - proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 2, - rows: 1, - cwd, - env: { - ...process.env, - TERM: 'xterm-256color' - } - }) - } catch (err) { - throw formatPtySpawnError(err, '/bin/sh', cwd) - } - - return new Promise((resolve, reject) => { - let settled = false - let exitDisposable: { dispose(): void } | undefined - const finish = (error?: Error, opts?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - exitDisposable?.dispose() - if (opts?.kill) { - try { - proc.kill() - } catch { - // Best-effort cleanup for a short-lived health probe. - } - } - if (error) { - reject(error) - return - } - resolve() - } - const timer = setTimeout(() => { - finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { - kill: true - }) - }, PTY_SPAWN_HEALTH_TIMEOUT_MS) - - // Why: ping only proves the protocol is alive; a real spawn catches stale node-pty helper paths. - exitDisposable = proc.onExit(({ exitCode }) => { - if (exitCode === 0) { - finish() - return - } - finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) - }) - }) -} - -/** - * Runs a short native PTY spawn probe for daemon health checks, retrying once - * so a transient stall (e.g. a busy machine right after an upgrade) does not - * mis-classify a healthy daemon as unable to spawn PTYs. - */ export async function checkPtySpawnHealth(): Promise { - if (process.platform === 'win32') { + if (!preflightPtySpawnHealth()) { return } - - // Why: a real short-lived spawn catches a deleted cwd or stale native PTY path before panes are routed to a daemon that can't spawn. - if (process.platform === 'darwin') { - ensureNodePtySpawnHelperExecutable() - } - preflightUnixPtySpawnEnvironment() - let lastError: unknown for (let attempt = 1; attempt <= PTY_SPAWN_HEALTH_RETRY_ATTEMPTS; attempt++) { try { - await runSinglePtySpawnHealthProbe() + await runPtySpawnHealthProbe() return - } catch (err) { - lastError = err + } catch (error) { + lastError = error if (attempt < PTY_SPAWN_HEALTH_RETRY_ATTEMPTS) { console.warn( `[daemon] PTY spawn health probe attempt ${attempt} failed; retrying`, - err instanceof Error ? err.message : err + error instanceof Error ? error.message : error ) } } @@ -538,883 +58,57 @@ export async function checkPtySpawnHealth(): Promise { throw lastError instanceof Error ? lastError : new Error(String(lastError)) } -/** - * Normalizes node-pty foreground process strings to executable basenames. - */ -function normalizeForegroundProcessName(processName: string | null | undefined): string | null { - const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' - if (!trimmed || trimmed === 'xterm-256color') { - return null - } - return trimmed.split(/[\\/]/).pop() || null -} - -/** - * Falls back to the spawned Windows shell when node-pty reports a terminal name. - */ -function resolveFallbackForegroundProcess( - processName: string | null | undefined, - shellPath: string -): string | null { - const normalized = normalizeForegroundProcessName(processName) - if (normalized || process.platform !== 'win32') { - return normalized - } - // Why: Windows node-pty can report the terminal name instead of the shell; fall back to the spawned shell. - return normalizeForegroundProcessName(pathWin32.basename(shellPath)) -} - -/** - * Spawns the daemon PTY, walking the Windows PowerShell -> cmd.exe fallback - * chain when ConPTY rejects the primary shell with ERROR_ACCESS_DENIED. - * - * Why: the daemon has no LocalPtyProvider, so it owns its chain walk; later attempts recompute args so the cmd.exe fallback still gets `chcp 65001`. - */ -function spawnDaemonPtyWithWindowsFallback(args: { - shellPath: string - shellArgs: string[] - spawnCwd: string - env: Record - cols: number - rows: number - windowsFallbackAttempts: WindowsShellSpawnAttempt[] - onMacosTccSpawnStrategy?: PtySubprocessOptions['onMacosTccSpawnStrategy'] -}): { - process: pty.IPty - shellPath: string - spawnCwd: string - startupCommandDeliveredInShellArgs?: boolean - /** False when a wrapper owns the reported status, so no exit code may be read from it. */ - reportsChildExitStatus: boolean -} { - let reportsChildExitStatus = true - const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { - const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) - // Why here: children inherit job membership, so the host job has to exist - // before the first pty -- but resolving it loads the ConPTY addon, and - // paying that at startup delayed the daemon's endpoint past the boot smoke - // test's readiness check. This path already pays ConPTY cost. Memoised. - if (process.platform === 'win32') { - assignHostProcessToKillOnCloseJob() - } - const proc = pty.spawn(wrapped.file, wrapped.args, { - name: args.env.TERM ?? 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd, - env: args.env, - // Why: legacy system ConPTY can corrupt full-width TUI rows in scrollback; bundled ConPTY has the wrap-marker behavior xterm expects. - ...(process.platform === 'win32' ? { useConptyDll: true } : {}) - }) - reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) - args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') - return proc - } - - try { - const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) - return { - process: process_, - shellPath: args.shellPath, - spawnCwd: args.spawnCwd, - reportsChildExitStatus - } - } catch (primaryErr) { - if (process.platform !== 'win32') { - throw primaryErr - } - // Skip the first entry: it is the primary that already failed above. - for (const attempt of args.windowsFallbackAttempts.slice(1)) { - try { - const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) - const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr) - console.warn( - `[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"` - ) - return { - process, - shellPath: attempt.shellPath, - spawnCwd: attempt.effectiveCwd, - startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, - reportsChildExitStatus - } - } catch { - // This fallback shell also failed -- try the next link in the chain. - } - } - throw primaryErr - } -} - /** * Spawns the daemon-owned PTY subprocess for a terminal session. * - * The returned handle records whether the startup command was already embedded - * in Windows shell args so the daemon host does not write it a second time. + * Launch planning stays ahead of validation so Windows validates the effective + * host or WSL cwd selected by the same fallback chain that native spawn uses. + * The handle then owns all event buffering, foreground identity, and teardown. */ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { const size = normalizePtySize(opts.cols, opts.rows) - const env: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - // Why: TUIs feature-gate on TERM_PROGRAM_VERSION; ORCA_APP_VERSION is inherited from the forking main process. - TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', - // Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links. - FORCE_HYPERLINK: '1' - } as Record - // Why: an older client may not ask a newly upgraded daemon to delete inherited shim state. - stripLegacyTerminalShimEnv(env, process.platform) - composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent) - deleteRequestedDaemonEnvKeys(env, opts.envToDelete) - if (opts.env?.TERM) { - env.TERM = opts.env.TERM - } - // Why: the daemon can inherit the pane identity of the terminal that launched `pn dev`; each PTY must opt into its own. - removeUnspecifiedPaneIdentityEnv(env, opts.env) - // Why: the daemon inherits an exported `fish_history` from the fish pane that - // launched Orca; only a session this spawn asked for may reach the shell, or - // panes write into the launching worktree's history file (STA-4682). - if (opts.env?.fish_history === undefined) { - dropInheritedOrcaFishHistory(env) - } - // Why the same for HISTFILE: it is exported too, so a daemon started from an - // Orca pane hands that worktree's history file to every pane this spawn did - // not scope itself — including panes of other worktrees. - if (opts.env?.HISTFILE === undefined) { - dropInheritedOrcaHistFile(env) - } - // Why ORCA_HISTFILE too: the desktop drops an inherited one on both of its - // branches, and the daemon inherits one from the pane that launched it just - // as readily. Left in place it now both WRAPS a pane the client scoped - // nothing for (shell-startup-features selects `history` on its presence) and - // makes the wrapper re-export another worktree's history path (#11146). - if (opts.env?.ORCA_HISTFILE === undefined) { - delete env.ORCA_HISTFILE - } - removeInheritedDevAgentHookEndpoint(env, opts.env) - removeInheritedElectronRunAsNode(env) - removeAppImageRuntimeEnv(env) - removeInheritedNoColor(env) + const env = createDaemonPtyEnvironment(opts) + const launch = createPtyShellLaunchPlan(opts, env) - env.LANG ??= 'en_US.UTF-8' - // Why: shellOverride must win over env.COMSPEC, or Windows always resolves to cmd.exe/PowerShell regardless of the user's pick. - const resolvedWslContext = resolveWslSessionContext(opts) - // Why: older persisted tabs can carry a PowerShell/cmd shellOverride; ignore it so WSL reconnects still enter the distro. - let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) - let shellArgs: string[] - let startupCommandDeliveredInShellArgs = false - let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] - const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) - const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' - // Why: gate on the effective cwd, not raw opts.cwd — an omitted cwd becomes a safe - // default (mirrors LocalPtyProvider). Guarding first treated undefined as root-like (#9578). - const requestedCwd = opts.cwd || getDefaultCwd() - if (opts.command && startupAgentRecognition) { - assertSafeAgentStartupCwd(requestedCwd, opts.command) - } - let spawnCwd = requestedCwd - let validationCwd = spawnCwd - - if (process.platform === 'win32') { - const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() - const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) - // Why: normalize concrete PowerShell paths to the family so the resolver can fall back to powershell.exe when pwsh.exe is unavailable. - const resolvedShellFamily: WindowsPowerShellShellFamily = - normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' - ? normalizedShellFamily - : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' - ? normalizedShellFamily - : undefined - const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ - shellFamily: resolvedShellFamily, - implementation: opts.terminalWindowsPowerShellImplementation - }) - const shouldResolvePowerShellFamily = - opts.terminalWindowsPowerShellImplementation !== undefined || - pathWin32.basename(shellPath) === shellPath - if (resolvedGitBashPath) { - shellPath = resolvedGitBashPath - } else if (shellPath === WINDOWS_GIT_BASH_SHELL) { - shellPath = 'powershell.exe' - } else { - shellPath = shouldResolvePowerShellFamily - ? (resolveEffectiveWindowsPowerShell({ - shellFamily: resolvedShellFamily, - implementation: opts.terminalWindowsPowerShellImplementation, - pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false - }) ?? shellPath) - : shellPath - } - if ( - pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' && - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) { - // Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead. - env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' - } - // Why: a bare `pwsh.exe` resolves to the Store App Execution Alias stub whose launch fails with ERROR_ACCESS_DENIED (5). - windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ - shellPath, - cwd: spawnCwd, - defaultCwd: getDefaultCwd(), - wslContext: resolvedWslContext, - startupCommand: opts.command - }) - const primaryAttempt = windowsFallbackAttempts[0] - if (primaryAttempt) { - shellPath = primaryAttempt.shellPath - shellArgs = primaryAttempt.shellArgs - spawnCwd = primaryAttempt.effectiveCwd - validationCwd = primaryAttempt.validationCwd - startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs - } else { - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - spawnCwd, - getDefaultCwd(), - resolvedWslContext, - opts.command, - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) - shellArgs = resolved.shellArgs - spawnCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true - } - if (isWindowsGitBashShellPath(shellPath)) { - // Why: Git for Windows login startup files otherwise cd to $HOME, ignoring node-pty's cwd. - env.CHERE_INVOKING ??= '1' - } - const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null - if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { - if (codexHomeWslInfo) { - const launchWslDistro = resolvedWslContext?.distro - if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } else { - env.CODEX_HOME = codexHomeWslInfo.linuxPath - env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath - // Why: wsl.exe only imports non-default env vars named in WSLENV. - addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - if (!launchWslDistro) { - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - requestedCwd, - getDefaultCwd(), - { - distro: codexHomeWslInfo.distro - }, - opts.command, - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) - shellArgs = resolved.shellArgs - spawnCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = - resolved.startupCommandDeliveredInShellArgs === true - } - } - } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { - // Why: host-local Codex home is unusable in WSL; let WSL Codex use its Linux-side ~/.codex. - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } else if (env.CODEX_HOME) { - addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - } - if (env.CLAUDE_CONFIG_DIR) { - // Why: non-default env vars need WSLENV import to cross Windows wsl.exe into the Linux side. - addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) - } - if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { - // Why: wsl.exe drops custom Windows env vars unless named in WSLENV. - addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) - } - } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { - // Why: WSL Codex homes are Linux paths; also drop ORCA_CODEX_HOME since shell-ready restores CODEX_HOME from it. - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } - if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { - addOrcaWslInteropEnv(env) - } - } else { - // Why: relay-side launch modes can ask for host defaults to stay scrubbed - // even after environment normalization above. - deleteRequestedDaemonEnvKeys(env, opts.envToDelete) - if (opts.env?.TERM) { - env.TERM = opts.env.TERM - } - // Why: set SHELL after the scrub and before launch-config derivation so shell-ready wrappers target the resolved shell. - const preferredShellPath = shellPath - shellPath = resolveUnixShellPath(shellPath) - if (shellPath !== preferredShellPath) { - env.SHELL = shellPath - console.warn( - `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` - ) - } - // Why: OpenCode/Codex path restoration, OMP's typed-command status wrapper, - // and the worktree HISTFILE repair all need shell-ready code that runs after - // the user's own startup files. - // Why: payload-bearing Codex startup text can be dropped by rc-file noise; plain Codex stays markerless for the startup-speed path. - const waitsForShellReady = - Boolean(opts.command) && - (!isCodexStartupCommand || - shouldUseShellReadyStartupDelivery({ - command: opts.command as string, - startupCommandDelivery: opts.startupCommandDelivery - })) - // Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch config - // below may name features for this shell. - delete env.ORCA_SHELL_FEATURES - const shellLaunch = getShellLaunchConfig( - shellPath, - selectShellStartupFeatures({ - shellPath, - env, - hasStartupCommand: Boolean(opts.command), - waitsForShellReady, - // Why identical: the identity marker exists so the readiness handshake - // can bind output to the right shell PID. - emitsStartupIdentity: waitsForShellReady - }) - ) - Object.assign(env, shellLaunch.env) - shellArgs = shellLaunch.args ?? ['-l'] - } - seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete }) - if ( - env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && - process.platform === 'win32' && - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' - ) { - addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) - } - const requestedEnv = opts.env - expandWindowsPathEnvironmentVariables(env) - // Why: collapse before promoting so the shim lands on the spelling the child actually inherits. - collapseWindowsPathEnvKeys(env, requestedEnv) - const requestedPath = requestedEnv - ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] - : undefined - promoteAgentTeamsShimPath(env, requestedPath) - // Why: raw requested PATH promotion runs after the inherited-env scrub. - stripLegacyTerminalShimEnv(env, process.platform) - // Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind. - dropIncoherentCondaActivationEnv(env, process.platform) - - // Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix. - ensureNodePtySpawnHelperExecutable() - preflightUnixPtySpawnEnvironment() - try { - await preflightPosixPtySpawnEnvironment(validationCwd, opts.cancelSignal) - await preflightWindowsPtySpawnEnvironment({ - validationCwd, - cwdWasExplicit: opts.cwd !== undefined, - ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) - }) - } catch (error) { - // Why: the wire must carry one cancellation identity. Clients key recovery - // off it, and an unrecognized message takes the rollback branch that closes - // a terminal the user still has (#7718). - if (error instanceof WorkingDirectoryValidationAbortedError) { - throw new TerminalAttachCanceledError(opts.sessionId) - } - throw error - } + await preflightPtySpawn({ + validationCwd: launch.validationCwd, + cwdWasExplicit: opts.cwd !== undefined, + sessionId: opts.sessionId, + ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) + }) if (opts.isCanceled?.()) { throw new TerminalAttachCanceledError(opts.sessionId) } - let proc: pty.IPty - let reportsChildExitStatus = true + let spawned: SpawnedDaemonPty try { - const spawned = spawnDaemonPtyWithWindowsFallback({ - shellPath, - shellArgs, - spawnCwd, + spawned = spawnNativeDaemonPty({ + shellPath: launch.shellPath, + shellArgs: launch.shellArgs, + spawnCwd: launch.spawnCwd, env, cols: size.cols, rows: size.rows, - windowsFallbackAttempts, + windowsFallbackAttempts: launch.windowsFallbackAttempts, onMacosTccSpawnStrategy: opts.onMacosTccSpawnStrategy }) - proc = spawned.process - // Why: a Windows fallback (e.g. cmd.exe) carries its own argv-embedded startup command; adopt the winning shell's identity + delivery flag. - shellPath = spawned.shellPath - spawnCwd = spawned.spawnCwd - reportsChildExitStatus = spawned.reportsChildExitStatus - if (spawned.startupCommandDeliveredInShellArgs !== undefined) { - startupCommandDeliveredInShellArgs = spawned.startupCommandDeliveredInShellArgs - } - } catch (err) { + } catch (error) { if (process.platform === 'win32') { - throw formatPtySpawnError(err, shellPath, spawnCwd) + throw formatPtySpawnError(error, launch.shellPath, launch.spawnCwd) } - throw err + throw error } - let onDataCb: ((data: string) => void) | null = null - let onExitCb: ((code: number, cause?: TerminalExitCause) => void) | null = null - let pendingPreListenerData: string[] = [] - let pendingPreListenerDataChars = 0 - let pendingPreListenerExitCode: number | null = null - let pendingPreListenerExitCause: TerminalExitCause | null = null - - const bufferPreListenerData = (data: string): void => { - // Why: Windows shell-arg startup commands can print before Session wires this subprocess in; preserve that spawn-time race window. - pendingPreListenerData.push(data) - pendingPreListenerDataChars += data.length - while (pendingPreListenerDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { - const removed = pendingPreListenerData.shift() - if (removed === undefined) { - pendingPreListenerDataChars = 0 - return - } - pendingPreListenerDataChars -= removed.length - } - } - - const flushPreListenerData = (): void => { - if (!onDataCb || pendingPreListenerData.length === 0) { - return - } - const pending = pendingPreListenerData - pendingPreListenerData = [] - pendingPreListenerDataChars = 0 - for (const data of pending) { - onDataCb(data) - } - } - - let lastOutputAt = 0 - proc.onData((data) => { - if (data.length > 0) { - lastOutputAt = Date.now() - } - if (onDataCb) { - onDataCb(data) - } else { - bufferPreListenerData(data) - } + return createDaemonPtySubprocessHandle({ + process: spawned.process, + shellPath: spawned.shellPath, + spawnCwd: spawned.spawnCwd, + env, + startupCommandDeliveredInShellArgs: + spawned.startupCommandDeliveredInShellArgs ?? launch.startupCommandDeliveredInShellArgs, + reportsChildExitStatus: spawned.reportsChildExitStatus, + requestedCwd: opts.cwd, + sessionId: opts.sessionId, + startupAgentRecognition: launch.startupAgentRecognition }) - proc.onExit(({ exitCode, signal }) => { - // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}, and a - // wrapper spawn reports the wrapper's status — so build the cause here, - // where both facts are still in hand, rather than shipping a bare number. - const cause = resolveProcessExitCause({ - exitCode, - signal, - hostReportsChildExitStatus: reportsChildExitStatus - }) - if (onExitCb) { - flushPreListenerData() - onExitCb(exitCode, cause) - } else { - pendingPreListenerExitCode = exitCode - pendingPreListenerExitCause = cause - } - }) - - // Why: node-pty throws Napi::Error if write/resize/kill hit a closed fd (child-exit vs onExit race); uncaught it std::terminates the daemon. - let dead = false - let disposed = false - let nodePtyKillIssued = false - let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null - const agentForegroundContextPaths = getAgentForegroundContextPaths({ - cwd: opts.cwd, - worktreeId: parsePtySessionId(opts.sessionId).worktreeId - }) - let startupAgentForeground: { processName: string; expiresAt: number } | null = - startupAgentRecognition - ? { - processName: startupAgentRecognition.processName, - expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS - } - : null - let foregroundRefreshInFlight = false - let lastForegroundRefreshStartedAt = 0 - const getFallbackForegroundProcess = (): string | null => - resolveFallbackForegroundProcess(proc.process, shellPath) - const getActiveStartupAgentForeground = ( - now = Date.now() - ): { processName: string; expiresAt: number } | null => { - if (!startupAgentForeground) { - return null - } - if (now > startupAgentForeground.expiresAt) { - startupAgentForeground = null - return null - } - return startupAgentForeground - } - const shouldInspectFallbackForegroundProcess = (fallbackProcess: string | null): boolean => - fallbackProcess !== null && - (isShellProcess(fallbackProcess) || - isAgentForegroundWrapperProcess(fallbackProcess) || - shouldInspectOuterWrapperFallback(fallbackProcess) || - // Why: agent-spawned helpers can become the PTY foreground child, but the Unix process tree still identifies the parent agent. - process.platform !== 'win32') - const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => { - if (dead || !proc.pid) { - return - } - const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess) - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if ( - !fallbackProcess || - (fallbackRecognition !== null && - !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || - !shouldInspectFallbackForegroundProcess(fallbackProcess) - ) { - return - } - const now = Date.now() - const idleNoEvidenceShell = - fallbackIsShell && !getActiveStartupAgentForeground(now) && !cachedAgentForeground - // Why: on Windows each refresh is a whole-table CIM scan, so only shells with no agent evidence and no recent output relax the retry. - const retryMs = !idleNoEvidenceShell - ? FOREGROUND_AGENT_CACHE_TTL_MS - : process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS - ? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS - : SHELL_FOREGROUND_REFRESH_RETRY_MS - if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) { - return - } - foregroundRefreshInFlight = true - lastForegroundRefreshStartedAt = now - // Why: daemon foreground reads are sync on the IPC hot path; refresh derived identities in the background and serve from a short cache. - // Why: Windows may need an async membership check before this shell/wrapper retirement policy is safe. - const retireStaleForegroundIdentity = (): void => { - const currentFallbackProcess = getFallbackForegroundProcess() - if ( - fallbackIsShell && - !getActiveStartupAgentForeground() && - currentFallbackProcess !== null && - isShellProcess(currentFallbackProcess) - ) { - cachedAgentForeground = null - startupAgentForeground = null - } else if ( - cachedAgentForeground !== null && - Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && - currentFallbackProcess !== null && - isAgentForegroundWrapperProcess(currentFallbackProcess) - ) { - // Why: an expired wrapper identity must not transfer to an unrelated wrapper (e.g. npm after an agent exit); fresh ones survive scan hiccups. - cachedAgentForeground = null - } - } - void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { - contextPaths: agentForegroundContextPaths - }) - .then(({ processName, available }) => { - if (dead) { - return - } - // Why: a degraded scan isn't exit evidence — retiring would fire false completion while the agent works under CIM load. - if (!available) { - return - } - if (!processName || !recognizeAgentProcess(processName)) { - // Why: a Windows snapshot can omit a live agent; only verified shell-only membership may retire cached identity. - if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { - return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => { - if (dead || consoleProcessIds === null || consoleProcessIds.size > 1) { - return - } - retireStaleForegroundIdentity() - }) - } - retireStaleForegroundIdentity() - return - } - cachedAgentForeground = { processName, refreshedAt: Date.now() } - startupAgentForeground = null - return processName - }) - .catch(() => { - // Best-effort only: foreground enrichment must never affect PTY health. - }) - .finally(() => { - foregroundRefreshInFlight = false - }) - } - proc.onExit(() => { - dead = true - cachedAgentForeground = null - startupAgentForeground = null - // Why: neutralize proc.kill synchronously in onExit so UnixTerminal's async socket-close SIGHUP can't land on a recycled pid. - // Windows excluded: WindowsTerminal.destroy needs kill() to close the ConPTY agent. - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - }) - - const slavePath = readPtySlavePath(proc) - return { - pid: proc.pid, - shellPath, - shellCwd: spawnCwd, - shellPathEnv: env.PATH, - ...(slavePath ? { slavePath } : {}), - ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), - getForegroundProcess: () => { - // Why: node-pty's `.process` reports the live foreground name but reads a recycled pid on a reaped pty, so bail when dead. - if (dead) { - return null - } - try { - const fallbackProcess = getFallbackForegroundProcess() - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - const inspectOuterWrapper = - fallbackRecognition !== null && - shouldInspectOuterWrapperForegroundProcess(fallbackRecognition) - if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) { - cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() } - startupAgentForeground = null - return fallbackProcess - } - scheduleAgentForegroundRefresh(fallbackProcess) - const now = Date.now() - if ( - cachedAgentForeground && - now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS - ) { - return cachedAgentForeground.processName - } - // Why: a wrapper (node/python) can't self-identify and readers poll slower than the cache TTL, so serve the last resolved agent. - // On Windows a shell fallback is an unreliable exit signal under ConPTY lag; trust the cache until a console-presence read confirms exit. - if ( - cachedAgentForeground && - fallbackProcess !== null && - (isAgentForegroundWrapperProcess(fallbackProcess) || - inspectOuterWrapper || - (process.platform === 'win32' && isShellProcess(fallbackProcess))) - ) { - return cachedAgentForeground.processName - } - const activeStartupAgentForeground = getActiveStartupAgentForeground(now) - if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) { - return activeStartupAgentForeground.processName - } - return fallbackProcess - } catch { - return null - } - }, - confirmForegroundProcess: async () => { - if (dead || !proc.pid) { - return null - } - try { - const fallbackProcess = getFallbackForegroundProcess() - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if ( - !fallbackProcess || - (fallbackRecognition !== null && - process.platform !== 'win32' && - !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || - (process.platform !== 'win32' && !shouldInspectFallbackForegroundProcess(fallbackProcess)) - ) { - return fallbackProcess - } - // Why: cached/in-flight scans may predate the OSC command boundary; confirmation needs a snapshot started afterward. - const resolution = await resolveAgentForegroundProcessWithAvailability( - proc.pid, - fallbackProcess, - { - contextPaths: agentForegroundContextPaths, - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) - } - : {}) - } - ) - if (dead || !resolution.available) { - return null - } - const recognized = recognizeAgentProcess(resolution.processName) - if (recognized) { - cachedAgentForeground = { - processName: recognized.processName, - refreshedAt: Date.now() - } - startupAgentForeground = null - return recognized.processName - } - // Why: a post-boundary scan resolving no agent is the authority that retires stale cached/startup identity. - cachedAgentForeground = null - startupAgentForeground = null - return resolution.processName - } catch { - return null - } - }, - write: (data) => { - if (dead) { - return - } - try { - proc.write(data) - } catch { - dead = true - } - }, - resize: (cols, rows) => { - if (dead) { - return - } - if (!isValidPtySize(cols, rows)) { - return - } - try { - proc.resize(cols, rows) - } catch { - dead = true - } - }, - // Why pause/resume work on Windows too: WindowsTerminal wires _socket to the ConPTY conout pipe, so pausing backpressures the child. - pause: () => { - if (dead) { - return - } - try { - proc.pause() - } catch { - /* native handle already torn down — flow control is best-effort */ - } - }, - resume: () => { - if (dead) { - return - } - try { - proc.resume() - } catch { - /* native handle already torn down — flow control is best-effort */ - } - }, - clear: () => { - if (dead) { - return - } - try { - proc.clear() - } catch { - // Best-effort: a clear on a just-exited PTY must not kill the handle. - } - }, - kill: () => { - if (dead) { - return - } - nodePtyKillIssued = true - try { - proc.kill() - } catch (error) { - // Why: a rejected native kill isn't proof of exit — keep the wrapper live so Session can retry the owner. - nodePtyKillIssued = false - throw error - } - }, - terminateOwnedTree: () => terminatePtyJob(proc), - forceKill: () => { - // Why: after reap/dispose proc.pid is a recycled pid, so SIGKILL would hit an unrelated process (forceKill only signals a live child). - if (dead) { - return - } - // Why: Windows node-pty kill already closed ConPTY; forcing again can double-close the native handle. - // That left forceKill a permanent no-op after any kill(), so a wedged ConPTY -- which - // never fires onExit -- had no escalation at all (#9854). The job is that escalation: - // it terminates the tree without touching the shell handle node-pty owns. - if (process.platform === 'win32' && nodePtyKillIssued) { - terminatePtyJob(proc) - return - } - try { - forceKillPosixPtyProcessGroups(proc.pid, () => { - process.kill(proc.pid, 'SIGKILL') - }) - } catch (signalError) { - try { - proc.kill() - nodePtyKillIssued = true - } catch { - nodePtyKillIssued = false - // Keep the original OS failure so callers can retry the same owner. - throw signalError - } - } - }, - signal: (sig) => { - // Why: same recycled-pid hazard as forceKill — once dead, dropping avoids signalling an unrelated process. - if (dead) { - return - } - const signalRootPid = (): void => { - try { - process.kill(proc.pid, sig) - } catch { - // Process may already be dead - } - } - // Why only SIGWINCH: the kernel delivers a real resize to the tty's foreground - // group, and proc.pid is never in it (the shell setpgid's away, and on macOS - // proc.pid is login(1), which drops SIGWINCH). Destructive signals keep the - // narrower root-pid target. - if (sig === 'SIGWINCH') { - signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid) - return - } - signalRootPid() - }, - onData: (cb) => { - onDataCb = cb - flushPreListenerData() - }, - onExit: (cb) => { - onExitCb = cb - if (pendingPreListenerExitCode !== null) { - const code = pendingPreListenerExitCode - const cause = pendingPreListenerExitCause ?? resolveProcessExitCause({ exitCode: code }) - pendingPreListenerExitCode = null - pendingPreListenerExitCause = null - flushPreListenerData() - cb(code, cause) - } - }, - dispose: () => { - if (disposed) { - return - } - disposed = true - dead = true - onDataCb = null - onExitCb = null - pendingPreListenerData = [] - pendingPreListenerDataChars = 0 - pendingPreListenerExitCode = null - pendingPreListenerExitCause = null - // Why: UnixTerminal.destroy()'s async socket-close SIGHUP can land on a recycled pid, hitting an unrelated user process; neutralize kill on POSIX. - // Windows destroy() uses kill() to close the ConPTY agent, so the guard is POSIX-only. - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } else if (nodePtyKillIssued) { - // Why: WindowsTerminal.destroy() calls kill(); destroying after node-pty's kill() double-closes the ConPTY handle (heap corruption). - return - } - try { - ;(proc as unknown as { destroy?: () => void }).destroy?.() - } catch { - /* swallow — already torn down, or native-side error we can't recover from */ - } - } - } } diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts new file mode 100644 index 00000000000..ff905e0cdfb --- /dev/null +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -0,0 +1,275 @@ +import type * as pty from 'node-pty' +import { win32 as pathWin32 } from 'node:path' +import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' +import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' +import { readWindowsConptyProcessIds } from '../../providers/windows-conpty-process-membership' +import { + isAgentForegroundWrapperProcess, + recognizeAgentProcess, + type RecognizedAgentProcess +} from '../../../shared/agent-process-recognition' +import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent' +import { isShellProcess } from '../../../shared/shell-process-detection' +import { parsePtySessionId } from '../pty-session-id' + +const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 +const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 +const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000 +const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000 +const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 + +function normalizeForegroundProcessName(processName: string | null | undefined): string | null { + const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' + if (!trimmed || trimmed === 'xterm-256color') { + return null + } + return trimmed.split(/[\\/]/).pop() || null +} + +function resolveFallbackForegroundProcess( + processName: string | null | undefined, + shellPath: string +): string | null { + const normalized = normalizeForegroundProcessName(processName) + if (normalized || process.platform !== 'win32') { + return normalized + } + return normalizeForegroundProcessName(pathWin32.basename(shellPath)) +} + +function shouldInspectOuterWrapperFallback(processName: string | null): boolean { + const recognized = recognizeAgentProcess(processName) + return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized) +} + +export type PtyForegroundProcessTracker = { + recordOutput(data: string): void + markDead(): void + getForegroundProcess(): string | null + confirmForegroundProcess(): Promise +} + +export function createPtyForegroundProcessTracker(args: { + process: pty.IPty + shellPath: string + cwd?: string + sessionId: string + startupAgentRecognition: RecognizedAgentProcess | null + isDead: () => boolean +}): PtyForegroundProcessTracker { + const proc = args.process + let lastOutputAt = 0 + let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null + const contextPaths = getAgentForegroundContextPaths({ + cwd: args.cwd, + worktreeId: parsePtySessionId(args.sessionId).worktreeId + }) + let startupAgentForeground: { processName: string; expiresAt: number } | null = + args.startupAgentRecognition + ? { + processName: args.startupAgentRecognition.processName, + expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS + } + : null + let foregroundRefreshInFlight = false + let lastForegroundRefreshStartedAt = 0 + const getFallbackProcess = (): string | null => + resolveFallbackForegroundProcess(proc.process, args.shellPath) + const getActiveStartupAgent = ( + now = Date.now() + ): { processName: string; expiresAt: number } | null => { + if (!startupAgentForeground) { + return null + } + if (now > startupAgentForeground.expiresAt) { + startupAgentForeground = null + return null + } + return startupAgentForeground + } + const shouldInspectFallback = (fallbackProcess: string | null): boolean => + fallbackProcess !== null && + (isShellProcess(fallbackProcess) || + isAgentForegroundWrapperProcess(fallbackProcess) || + shouldInspectOuterWrapperFallback(fallbackProcess) || + process.platform !== 'win32') + + const scheduleRefresh = (fallbackProcess: string | null): void => { + if (args.isDead() || !proc.pid) { + return + } + const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess) + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + if ( + !fallbackProcess || + (fallbackRecognition !== null && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || + !shouldInspectFallback(fallbackProcess) + ) { + return + } + const now = Date.now() + const idleNoEvidenceShell = + fallbackIsShell && !getActiveStartupAgent(now) && !cachedAgentForeground + const retryMs = !idleNoEvidenceShell + ? FOREGROUND_AGENT_CACHE_TTL_MS + : process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS + ? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS + : SHELL_FOREGROUND_REFRESH_RETRY_MS + if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) { + return + } + foregroundRefreshInFlight = true + lastForegroundRefreshStartedAt = now + const retireStaleForegroundIdentity = (): void => { + const currentFallbackProcess = getFallbackProcess() + if ( + fallbackIsShell && + !getActiveStartupAgent() && + currentFallbackProcess !== null && + isShellProcess(currentFallbackProcess) + ) { + cachedAgentForeground = null + startupAgentForeground = null + } else if ( + cachedAgentForeground !== null && + Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && + currentFallbackProcess !== null && + isAgentForegroundWrapperProcess(currentFallbackProcess) + ) { + cachedAgentForeground = null + } + } + void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { + contextPaths + }) + .then(({ processName, available }) => { + if (args.isDead() || !available) { + return + } + if (!processName || !recognizeAgentProcess(processName)) { + if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { + return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => { + if (args.isDead() || consoleProcessIds === null || consoleProcessIds.size > 1) { + return + } + retireStaleForegroundIdentity() + }) + } + retireStaleForegroundIdentity() + return + } + cachedAgentForeground = { processName, refreshedAt: Date.now() } + startupAgentForeground = null + return processName + }) + .catch(() => { + // Best-effort only: foreground enrichment must never affect PTY health. + }) + .finally(() => { + foregroundRefreshInFlight = false + }) + } + + return { + recordOutput: (data) => { + if (data.length > 0) { + lastOutputAt = Date.now() + } + }, + markDead: () => { + cachedAgentForeground = null + startupAgentForeground = null + }, + getForegroundProcess: () => { + if (args.isDead()) { + return null + } + try { + const fallbackProcess = getFallbackProcess() + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + const inspectOuterWrapper = + fallbackRecognition !== null && + shouldInspectOuterWrapperForegroundProcess(fallbackRecognition) + if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) { + cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() } + startupAgentForeground = null + return fallbackProcess + } + scheduleRefresh(fallbackProcess) + const now = Date.now() + if ( + cachedAgentForeground && + now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS + ) { + return cachedAgentForeground.processName + } + if ( + cachedAgentForeground && + fallbackProcess !== null && + (isAgentForegroundWrapperProcess(fallbackProcess) || + inspectOuterWrapper || + (process.platform === 'win32' && isShellProcess(fallbackProcess))) + ) { + return cachedAgentForeground.processName + } + const activeStartupAgentForeground = getActiveStartupAgent(now) + if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) { + return activeStartupAgentForeground.processName + } + return fallbackProcess + } catch { + return null + } + }, + confirmForegroundProcess: async () => { + if (args.isDead() || !proc.pid) { + return null + } + try { + const fallbackProcess = getFallbackProcess() + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + if ( + !fallbackProcess || + (fallbackRecognition !== null && + process.platform !== 'win32' && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || + (process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess)) + ) { + return fallbackProcess + } + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + fallbackProcess, + { + contextPaths, + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) + } + : {}) + } + ) + if (args.isDead() || !resolution.available) { + return null + } + const recognized = recognizeAgentProcess(resolution.processName) + if (recognized) { + cachedAgentForeground = { + processName: recognized.processName, + refreshedAt: Date.now() + } + startupAgentForeground = null + return recognized.processName + } + cachedAgentForeground = null + startupAgentForeground = null + return resolution.processName + } catch { + return null + } + } + } +} diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts new file mode 100644 index 00000000000..e0332ba9921 --- /dev/null +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -0,0 +1,82 @@ +import * as pty from 'node-pty' +import { + hostReportsChildExitStatus, + wrapShellSpawnForMacosTccAttribution +} from '../../providers/macos-tcc-login-shell' +import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain' +import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' + +export type SpawnedDaemonPty = { + process: pty.IPty + shellPath: string + spawnCwd: string + startupCommandDeliveredInShellArgs?: boolean + /** False when a wrapper owns the reported status, so no exit code may be read from it. */ + reportsChildExitStatus: boolean +} + +/** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */ +export function spawnNativeDaemonPty(args: { + shellPath: string + shellArgs: string[] + spawnCwd: string + env: Record + cols: number + rows: number + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void +}): SpawnedDaemonPty { + let reportsChildExitStatus = true + const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { + const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) + // Why: children inherit job membership, so the host job must exist before the first Windows PTY. + if (process.platform === 'win32') { + assignHostProcessToKillOnCloseJob() + } + const proc = pty.spawn(wrapped.file, wrapped.args, { + name: args.env.TERM ?? 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd, + env: args.env, + // Why: bundled ConPTY has the wrap-marker behavior xterm expects. + ...(process.platform === 'win32' ? { useConptyDll: true } : {}) + }) + reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) + args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') + return proc + } + + try { + const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) + return { + process: process_, + shellPath: args.shellPath, + spawnCwd: args.spawnCwd, + reportsChildExitStatus + } + } catch (primaryErr) { + if (process.platform !== 'win32') { + throw primaryErr + } + for (const attempt of args.windowsFallbackAttempts.slice(1)) { + try { + const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) + const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr) + console.warn( + `[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"` + ) + return { + process, + shellPath: attempt.shellPath, + spawnCwd: attempt.effectiveCwd, + startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, + reportsChildExitStatus + } + } catch { + // This fallback shell also failed -- try the next link in the chain. + } + } + throw primaryErr + } +} diff --git a/src/main/daemon/pty-subprocess/pre-listener-events.ts b/src/main/daemon/pty-subprocess/pre-listener-events.ts new file mode 100644 index 00000000000..f7eb7216e58 --- /dev/null +++ b/src/main/daemon/pty-subprocess/pre-listener-events.ts @@ -0,0 +1,90 @@ +import { + resolveProcessExitCause, + type TerminalExitCause +} from '../../../shared/terminal-exit-cause' + +const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 + +type DataListener = (data: string) => void +type ExitListener = (code: number, cause?: TerminalExitCause) => void + +/** Preserves spawn-time output and exit ordering until Session installs its listeners. */ +export class PtyPreListenerEvents { + private onDataCb: DataListener | null = null + private onExitCb: ExitListener | null = null + private pendingData: string[] = [] + private pendingDataChars = 0 + private pendingExitCode: number | null = null + private pendingExitCause: TerminalExitCause | null = null + + acceptData(data: string): void { + if (this.onDataCb) { + this.onDataCb(data) + return + } + this.pendingData.push(data) + this.pendingDataChars += data.length + while (this.pendingDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { + const removed = this.pendingData.shift() + if (removed === undefined) { + this.pendingDataChars = 0 + return + } + this.pendingDataChars -= removed.length + } + } + + acceptExit(args: { + exitCode: number + signal?: number + hostReportsChildExitStatus: boolean + }): void { + const cause = resolveProcessExitCause(args) + if (this.onExitCb) { + this.flushData() + this.onExitCb(args.exitCode, cause) + } else { + this.pendingExitCode = args.exitCode + this.pendingExitCause = cause + } + } + + onData(cb: DataListener): void { + this.onDataCb = cb + this.flushData() + } + + onExit(cb: ExitListener): void { + this.onExitCb = cb + if (this.pendingExitCode === null) { + return + } + const code = this.pendingExitCode + const cause = this.pendingExitCause ?? resolveProcessExitCause({ exitCode: code }) + this.pendingExitCode = null + this.pendingExitCause = null + this.flushData() + cb(code, cause) + } + + clear(): void { + this.onDataCb = null + this.onExitCb = null + this.pendingData = [] + this.pendingDataChars = 0 + this.pendingExitCode = null + this.pendingExitCause = null + } + + private flushData(): void { + if (!this.onDataCb || this.pendingData.length === 0) { + return + } + const pending = this.pendingData + this.pendingData = [] + this.pendingDataChars = 0 + for (const data of pending) { + this.onDataCb(data) + } + } +} diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts new file mode 100644 index 00000000000..ed8cd5e6a22 --- /dev/null +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -0,0 +1,234 @@ +import { win32 as pathWin32 } from 'node:path' +import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash' +import { isPwshAvailable } from '../../pwsh' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env' +import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env' +import { + POWERLEVEL10K_WIZARD_DISABLE_ENV, + seedPowerlevel10kWizardEnv +} from '../../pty/powerlevel10k-wizard-env' +import { + resolveSafePtyDefaultCwd, + assertSafeAgentStartupCwd +} from '../../providers/pty-default-cwd' +import { + resolveEffectiveWindowsPowerShell, + shouldProbeWindowsPowerShellAvailability, + type WindowsPowerShellShellFamily +} from '../../providers/windows-powershell' +import { + buildWindowsPowerShellSpawnAttempts, + type WindowsShellSpawnAttempt +} from '../../providers/windows-shell-fallback-chain' +import { + ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, + resolveWindowsShellLaunchArgs +} from '../../providers/windows-shell-args' +import { resolveUnixShellPath } from '../../providers/local-pty-utils' +import { selectShellStartupFeatures } from '../../shell-startup-features' +import { parseWslPath } from '../../wsl' +import { addWslEnvKeys } from '../../wsl-env' +import { + recognizeAgentProcessFromCommandLine, + type RecognizedAgentProcess +} from '../../../shared/agent-process-recognition' +import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query' +import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' +import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready' +import { resolveWslSessionContext } from '../wsl-session-context' +import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment' +import type { PtySubprocessOptions } from '../pty-subprocess' + +export type PtyShellLaunchPlan = { + shellPath: string + shellArgs: string[] + spawnCwd: string + validationCwd: string + startupCommandDeliveredInShellArgs: boolean + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + startupAgentRecognition: RecognizedAgentProcess | null +} + +export function createPtyShellLaunchPlan( + opts: PtySubprocessOptions, + env: Record +): PtyShellLaunchPlan { + const resolvedWslContext = resolveWslSessionContext(opts) + let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) + let shellArgs: string[] + let startupCommandDeliveredInShellArgs = false + let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] + const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) + const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' + const requestedCwd = opts.cwd || resolveSafePtyDefaultCwd() + if (opts.command && startupAgentRecognition) { + assertSafeAgentStartupCwd(requestedCwd, opts.command) + } + let spawnCwd = requestedCwd + let validationCwd = spawnCwd + + if (process.platform === 'win32') { + const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() + const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) + const resolvedShellFamily: WindowsPowerShellShellFamily = + normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' + ? normalizedShellFamily + : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' + ? normalizedShellFamily + : undefined + const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ + shellFamily: resolvedShellFamily, + implementation: opts.terminalWindowsPowerShellImplementation + }) + const shouldResolvePowerShellFamily = + opts.terminalWindowsPowerShellImplementation !== undefined || + pathWin32.basename(shellPath) === shellPath + if (resolvedGitBashPath) { + shellPath = resolvedGitBashPath + } else if (shellPath === WINDOWS_GIT_BASH_SHELL) { + shellPath = 'powershell.exe' + } else { + shellPath = shouldResolvePowerShellFamily + ? (resolveEffectiveWindowsPowerShell({ + shellFamily: resolvedShellFamily, + implementation: opts.terminalWindowsPowerShellImplementation, + pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false + }) ?? shellPath) + : shellPath + } + if ( + pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' && + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) { + env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' + } + windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ + shellPath, + cwd: spawnCwd, + defaultCwd: resolveSafePtyDefaultCwd(), + wslContext: resolvedWslContext, + startupCommand: opts.command + }) + const primaryAttempt = windowsFallbackAttempts[0] + if (primaryAttempt) { + shellPath = primaryAttempt.shellPath + shellArgs = primaryAttempt.shellArgs + spawnCwd = primaryAttempt.effectiveCwd + validationCwd = primaryAttempt.validationCwd + startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs + } else { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + spawnCwd, + resolveSafePtyDefaultCwd(), + resolvedWslContext, + opts.command, + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true + } + if (isWindowsGitBashShellPath(shellPath)) { + env.CHERE_INVOKING ??= '1' + } + const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + const launchWslDistro = resolvedWslContext?.distro + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else { + env.CODEX_HOME = codexHomeWslInfo.linuxPath + env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + requestedCwd, + resolveSafePtyDefaultCwd(), + { distro: codexHomeWslInfo.distro }, + opts.command, + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true + } + } + } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else if (env.CODEX_HOME) { + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (env.CLAUDE_CONFIG_DIR) { + addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) + } + if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + addOrcaWslInteropEnv(env) + } + } else { + rescrubDaemonPtyEnvironment(env, opts) + const preferredShellPath = shellPath + shellPath = resolveUnixShellPath(shellPath) + if (shellPath !== preferredShellPath) { + env.SHELL = shellPath + console.warn( + `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` + ) + } + const waitsForShellReady = + Boolean(opts.command) && + (!isCodexStartupCommand || + shouldUseShellReadyStartupDelivery({ + command: opts.command as string, + startupCommandDelivery: opts.startupCommandDelivery + })) + delete env.ORCA_SHELL_FEATURES + const shellLaunch = getShellLaunchConfig( + shellPath, + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: Boolean(opts.command), + waitsForShellReady, + emitsStartupIdentity: waitsForShellReady + }) + ) + Object.assign(env, shellLaunch.env) + shellArgs = shellLaunch.args ?? ['-l'] + } + + seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete }) + if ( + env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && + process.platform === 'win32' && + pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + ) { + addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) + } + finalizeDaemonPtyEnvironment(env, opts.env) + + return { + shellPath, + shellArgs, + spawnCwd, + validationCwd, + startupCommandDeliveredInShellArgs, + windowsFallbackAttempts, + startupAgentRecognition + } +} diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts new file mode 100644 index 00000000000..cbace940f46 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -0,0 +1,189 @@ +import { delimiter } from 'node:path' +import { dropInheritedOrcaFishHistory } from '../../fish-history-session' +import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env' +import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env' +import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env' +import { stripLegacyTerminalShimEnv } from '../../pty/legacy-terminal-shim-dir' +import { removeInheritedNoColor } from '../../pty/terminal-color-env' +import { resolvePathEnvKey } from '../../pty/windows-environment-path' +import { dropInheritedOrcaHistFile } from '../../worktree-history-file-path' +import { + gitCredentialPromptGuardEnv, + mergeGitConfigEnvProtocol +} from '../../../shared/git-credential-prompt-env' +import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../../shared/terminal-git-credential-guard' +import { + expandWindowsEnvironmentVariables, + expandWindowsPathEnvironmentVariables +} from '../../../shared/windows-environment-expansion' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { PtySubprocessOptions } from '../pty-subprocess' + +const PANE_IDENTITY_ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN' +] as const +const WINDOWS_PATH_ENV_KEY_RE = /^path$/i + +function composeGuardedDaemonGitConfigEnv( + env: Record, + explicitEnv: Record | undefined, + launchAgent: TuiAgent | undefined +): void { + const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] + delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] + if (policy !== 'guard' && launchAgent === undefined) { + return + } + // Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard. + Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform)) +} + +function deleteRequestedDaemonEnvKeys( + env: Record, + keys: readonly string[] | undefined +): void { + // Why: persistent daemon state can differ from Electron; delete CODEX_HOME only when its Orca overlay owns it. + const deleteOrcaOwnedCodexHome = + keys?.includes('ORCA_CODEX_HOME') === true && + env.ORCA_CODEX_HOME !== undefined && + env.CODEX_HOME === env.ORCA_CODEX_HOME + for (const key of keys ?? []) { + delete env[key] + } + if (deleteOrcaOwnedCodexHome) { + delete env.CODEX_HOME + } +} + +function removeUnspecifiedPaneIdentityEnv( + env: Record, + explicitEnv: Record | undefined +): void { + for (const key of PANE_IDENTITY_ENV_KEYS) { + if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { + delete env[key] + } + } +} + +/** Removes the second PATH key only when the daemon's env merge created it. */ +function collapseWindowsPathEnvKeys( + env: Record, + requestedEnv: Record | undefined +): void { + if (process.platform !== 'win32') { + return + } + const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + if (pathKeys.length < 2) { + return + } + // Why: a one-key main patch is authoritative; zero or two keys came from inherited state. + const requestedKeys = requestedEnv + ? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + : [] + if (requestedKeys.length !== 1) { + return + } + const survivingKey = requestedKeys[0] + if (!survivingKey || env[survivingKey] === undefined) { + return + } + for (const key of pathKeys) { + if (key !== survivingKey) { + delete env[key] + } + } +} + +/** Promotes the agent-teams shim path ahead of inherited PATH entries. */ +function promoteAgentTeamsShimPath( + env: Record, + requestedPath: string | undefined +): void { + if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { + return + } + const normalizedRequestedPath = + process.platform === 'win32' + ? expandWindowsEnvironmentVariables(requestedPath, env) + : requestedPath + const pathDelimiter = process.platform === 'win32' ? ';' : delimiter + const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] + if (!shimDir) { + return + } + const pathKey = resolvePathEnvKey(env, process.platform) + const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] + env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) +} + +function removeInheritedDevAgentHookEndpoint( + env: Record, + explicitEnv: Record | undefined +): void { + if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) { + // Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec. + delete env.ORCA_AGENT_HOOK_ENDPOINT + } +} + +export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record { + const env: Record = { + ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', + FORCE_HYPERLINK: '1' + } as Record + stripLegacyTerminalShimEnv(env, process.platform) + composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent) + deleteRequestedDaemonEnvKeys(env, opts.envToDelete) + if (opts.env?.TERM) { + env.TERM = opts.env.TERM + } + removeUnspecifiedPaneIdentityEnv(env, opts.env) + if (opts.env?.fish_history === undefined) { + dropInheritedOrcaFishHistory(env) + } + if (opts.env?.HISTFILE === undefined) { + dropInheritedOrcaHistFile(env) + } + if (opts.env?.ORCA_HISTFILE === undefined) { + delete env.ORCA_HISTFILE + } + removeInheritedDevAgentHookEndpoint(env, opts.env) + delete env.ELECTRON_RUN_AS_NODE + removeAppImageRuntimeEnv(env) + removeInheritedNoColor(env) + env.LANG ??= 'en_US.UTF-8' + return env +} + +export function rescrubDaemonPtyEnvironment( + env: Record, + opts: PtySubprocessOptions +): void { + deleteRequestedDaemonEnvKeys(env, opts.envToDelete) + if (opts.env?.TERM) { + env.TERM = opts.env.TERM + } +} + +export function finalizeDaemonPtyEnvironment( + env: Record, + requestedEnv: Record | undefined +): void { + expandWindowsPathEnvironmentVariables(env) + collapseWindowsPathEnvKeys(env, requestedEnv) + const requestedPath = requestedEnv + ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] + : undefined + promoteAgentTeamsShimPath(env, requestedPath) + stripLegacyTerminalShimEnv(env, process.platform) + dropIncoherentCondaActivationEnv(env, process.platform) +} diff --git a/src/main/daemon/pty-subprocess/spawn-preflight.ts b/src/main/daemon/pty-subprocess/spawn-preflight.ts new file mode 100644 index 00000000000..facaf0a69bd --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-preflight.ts @@ -0,0 +1,221 @@ +import * as pty from 'node-pty' +import { statSync } from 'node:fs' +import { release } from 'node:os' +import { + ensureNodePtySpawnHelperExecutable, + getNodePtySpawnHelperCandidates, + validateWorkingDirectoryAsync, + WorkingDirectoryValidationAbortedError +} from '../../providers/local-pty-utils' +import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd' +import { TerminalAttachCanceledError } from '../daemon-errors' +import { DaemonProtocolError } from '../types' + +const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 + +function daemonEnvironmentDiagSuffix(): string { + const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' + const systemVersion = + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || + release() + return ` (orca: ${orca}, arch: ${process.arch}, platform: ${process.platform} ${systemVersion})` +} + +function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { + const detailName = kind === 'helper' ? 'helper' : 'cwd' + const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' + const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' + return new DaemonProtocolError( + `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${daemonEnvironmentDiagSuffix()}` + ) +} + +function isExistingDirectory(path: string | undefined): path is string { + if (!path) { + return false + } + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +function repairDaemonCwd(): string | null { + const candidates = [process.env.ORCA_USER_DATA_PATH] + try { + candidates.push(resolveSafePtyDefaultCwd()) + } catch { + // Keep daemon cwd repair best-effort even when no user terminal cwd is safe. + } + candidates.push(process.platform === 'win32' ? 'C:\\' : '/') + for (const candidate of candidates) { + if (isExistingDirectory(candidate)) { + try { + process.chdir(candidate) + return candidate + } catch { + // Try the next stable cwd candidate. + } + } + } + return null +} + +function preflightDaemonCwd(): void { + let daemonCwd = '' + try { + daemonCwd = process.cwd() + if (isExistingDirectory(daemonCwd)) { + return + } + } catch { + // Recover below; process.cwd() throws after the original cwd is deleted. + } + if (repairDaemonCwd()) { + return + } + throw formatMissingDaemonPathError('cwd', daemonCwd) +} + +function preflightMacNodePtySpawnEnvironment(): void { + if (process.platform !== 'darwin') { + return + } + let candidates: string[] + try { + candidates = getNodePtySpawnHelperCandidates() + } catch { + throw formatMissingDaemonPathError('helper', '') + } + for (const candidate of candidates) { + try { + if (statSync(candidate).isFile()) { + return + } + } catch { + // Try the next node-pty native location. + } + } + throw formatMissingDaemonPathError('helper', candidates[0] ?? '') +} + +function preflightUnixPtySpawnEnvironment(): void { + if (process.platform === 'win32') { + return + } + // Why: detached daemons can outlive their launch cwd; repair before every spawn. + preflightDaemonCwd() + preflightMacNodePtySpawnEnvironment() +} + +function isNativeWindowsPath(path: string): boolean { + return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') +} + +export async function preflightPtySpawn(args: { + validationCwd: string + cwdWasExplicit: boolean + sessionId: string + signal?: AbortSignal +}): Promise { + ensureNodePtySpawnHelperExecutable() + preflightUnixPtySpawnEnvironment() + try { + if (process.platform === 'win32') { + if (args.cwdWasExplicit && isNativeWindowsPath(args.validationCwd)) { + await validateWorkingDirectoryAsync( + args.validationCwd, + args.signal ? { signal: args.signal } : {} + ) + } + } else { + await validateWorkingDirectoryAsync( + args.validationCwd, + args.signal ? { signal: args.signal } : {} + ) + } + } catch (error) { + if (error instanceof WorkingDirectoryValidationAbortedError) { + throw new TerminalAttachCanceledError(args.sessionId) + } + throw error + } +} + +export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { + const message = err instanceof Error ? err.message : String(err) + const formatted = new DaemonProtocolError( + `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${daemonEnvironmentDiagSuffix()}` + ) + if (err instanceof Error && err.stack) { + formatted.stack = err.stack + } + return formatted +} + +export function runPtySpawnHealthProbe(): Promise { + const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) + ? process.env.ORCA_USER_DATA_PATH + : resolveSafePtyDefaultCwd() + let proc: pty.IPty + try { + proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env: { ...process.env, TERM: 'xterm-256color' } + }) + } catch (err) { + throw formatPtySpawnError(err, '/bin/sh', cwd) + } + + return new Promise((resolve, reject) => { + let settled = false + let exitDisposable: { dispose(): void } | undefined + const finish = (error?: Error, opts?: { kill?: boolean }): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + exitDisposable?.dispose() + if (opts?.kill) { + try { + proc.kill() + } catch { + // Best-effort cleanup for a short-lived health probe. + } + } + if (error) { + reject(error) + } else { + resolve() + } + } + const timer = setTimeout(() => { + finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { + kill: true + }) + }, PTY_SPAWN_HEALTH_TIMEOUT_MS) + exitDisposable = proc.onExit(({ exitCode }) => { + if (exitCode === 0) { + finish() + } else { + finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) + } + }) + }) +} + +export function preflightPtySpawnHealth(): boolean { + if (process.platform === 'win32') { + return false + } + if (process.platform === 'darwin') { + ensureNodePtySpawnHelperExecutable() + } + preflightUnixPtySpawnEnvironment() + return true +} diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts new file mode 100644 index 00000000000..a56d0fc3ae2 --- /dev/null +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -0,0 +1,202 @@ +import type * as pty from 'node-pty' +import type { RecognizedAgentProcess } from '../../../shared/agent-process-recognition' +import { readPtySlavePath } from '../../../shared/pty-slave-line-discipline-echo' +import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups' +import { signalPosixPtyForegroundGroup } from '../../pty/posix-pty-foreground-group' +import { readPtsName } from '../../pty/node-pty-pts-name' +import { terminatePtyJob } from '../../windows/windows-pty-job' +import { isValidPtySize } from '../daemon-pty-size' +import type { SubprocessHandle } from '../session-subprocess-handle' +import { createPtyForegroundProcessTracker } from './foreground-process-tracker' +import { PtyPreListenerEvents } from './pre-listener-events' + +type DisposableNativePty = pty.IPty & { destroy?: () => void } + +export function createDaemonPtySubprocessHandle(args: { + process: pty.IPty + shellPath: string + spawnCwd: string + env: Record + startupCommandDeliveredInShellArgs: boolean + reportsChildExitStatus: boolean + requestedCwd?: string + sessionId: string + startupAgentRecognition: RecognizedAgentProcess | null +}): SubprocessHandle { + const proc = args.process + // node-pty exposes destroy at runtime but omits it from IPty. + const nativeProc = proc as DisposableNativePty + const events = new PtyPreListenerEvents() + let dead = false + let disposed = false + let nodePtyKillIssued = false + const foreground = createPtyForegroundProcessTracker({ + process: proc, + shellPath: args.shellPath, + cwd: args.requestedCwd, + sessionId: args.sessionId, + startupAgentRecognition: args.startupAgentRecognition, + isDead: () => dead + }) + + proc.onData((data) => { + foreground.recordOutput(data) + events.acceptData(data) + }) + proc.onExit(({ exitCode, signal }) => { + events.acceptExit({ + exitCode, + signal, + hostReportsChildExitStatus: args.reportsChildExitStatus + }) + }) + proc.onExit(() => { + dead = true + foreground.markDead() + // Why: neutralize kill synchronously so a later async socket-close SIGHUP cannot hit a recycled pid. + if (process.platform !== 'win32') { + nativeProc.kill = () => {} + } + }) + + const slavePath = readPtySlavePath(proc) + return { + pid: proc.pid, + shellPath: args.shellPath, + shellCwd: args.spawnCwd, + shellPathEnv: args.env.PATH, + ...(slavePath ? { slavePath } : {}), + ...(args.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), + getForegroundProcess: foreground.getForegroundProcess, + confirmForegroundProcess: foreground.confirmForegroundProcess, + write: (data) => { + if (dead) { + return + } + try { + proc.write(data) + } catch { + dead = true + } + }, + resize: (cols, rows) => { + if (dead || !isValidPtySize(cols, rows)) { + return + } + try { + proc.resize(cols, rows) + } catch { + dead = true + } + }, + // WindowsTerminal also wires _socket to the ConPTY conout pipe, so pausing backpressures the child. + pause: () => { + if (dead) { + return + } + try { + proc.pause() + } catch { + // Native handle already torn down; flow control is best-effort. + } + }, + resume: () => { + if (dead) { + return + } + try { + proc.resume() + } catch { + // Native handle already torn down; flow control is best-effort. + } + }, + clear: () => { + if (dead) { + return + } + try { + proc.clear() + } catch { + // A clear on a just-exited PTY is best-effort. + } + }, + kill: () => { + if (dead) { + return + } + nodePtyKillIssued = true + try { + proc.kill() + } catch (error) { + // A rejected native kill is not proof of exit; keep the wrapper live for a retry. + nodePtyKillIssued = false + throw error + } + }, + terminateOwnedTree: () => terminatePtyJob(proc), + forceKill: () => { + if (dead) { + return + } + // Escalate a ConPTY kill through the job without double-closing node-pty's shell handle. + if (process.platform === 'win32' && nodePtyKillIssued) { + terminatePtyJob(proc) + return + } + try { + forceKillPosixPtyProcessGroups(proc.pid, () => { + process.kill(proc.pid, 'SIGKILL') + }) + } catch (signalError) { + try { + proc.kill() + nodePtyKillIssued = true + } catch { + nodePtyKillIssued = false + throw signalError + } + } + }, + signal: (sig) => { + if (dead) { + return + } + const signalRootPid = (): void => { + try { + process.kill(proc.pid, sig) + } catch { + // Process may already be dead. + } + } + // SIGWINCH belongs to the tty foreground group; destructive signals keep the root-pid target. + if (sig === 'SIGWINCH') { + signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid) + return + } + signalRootPid() + }, + onData: (cb) => events.onData(cb), + onExit: (cb) => events.onExit(cb), + dispose: () => { + if (disposed) { + return + } + disposed = true + dead = true + events.clear() + // POSIX destroy() can asynchronously signal a recycled pid; Windows needs kill() to close ConPTY. + if (process.platform !== 'win32') { + nativeProc.kill = () => {} + } else if (nodePtyKillIssued) { + return + } + try { + nativeProc.destroy?.() + } catch { + // Native handle was already torn down. + } + } + } +} diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 1cb2a75f232..c606b510a50 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -129,6 +129,8 @@ async function spawnAndPublishSession( }) if (opts.isCanceled?.()) { + // Make the canceled spawn non-advertisable before cleanup ownership enters the host map. + session.beginTermination() // Retain cleanup ownership if the native child refuses to exit. deps.sessions.set(opts.sessionId, session) await session.forceKillAndDisposeSubprocess() diff --git a/src/main/daemon/terminal-host-session-listing.ts b/src/main/daemon/terminal-host-session-listing.ts index 3ae2d5e845e..69a3b8783cb 100644 --- a/src/main/daemon/terminal-host-session-listing.ts +++ b/src/main/daemon/terminal-host-session-listing.ts @@ -8,7 +8,7 @@ export function listLiveTerminalHostSessions( ): SessionInfo[] { const result: SessionInfo[] = [] for (const session of sessions.values()) { - if (!session.isAlive) { + if (!session.isAlive || session.isTerminating) { continue } const size = session.getAppliedSize() diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index 01dc111ae1b..30494f29502 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -21,7 +21,6 @@ main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts main/codex/codex-wsl-hook-install-plan.ts -main/daemon/pty-subprocess.ts main/git/runner.ts main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts