From 7adda25b0a72598ea92f7955045adaa158da54cd Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:38:44 -0700 Subject: [PATCH 01/35] fix(daemon): retire empty current-generation daemons (#9277) * fix(daemon): retire empty current-generation daemons Co-authored-by: Orca * fix(daemon): retire empty daemons on disconnect Co-authored-by: Orca * test(daemon): authenticate Windows lifecycle harness Co-authored-by: Orca * test(daemon): assert remaining shutdown budget Co-authored-by: Orca --------- Co-authored-by: Orca --- .../windows-daemon-workspace-close-repro.mjs | 122 ++- .../2026-07-18-daemon-lifecycle-retirement.md | 279 ++++++ src/main/daemon/client.test.ts | 100 +- src/main/daemon/client.ts | 188 +++- src/main/daemon/daemon-entry.test.ts | 43 + src/main/daemon/daemon-entry.ts | 39 +- ...n-foreground-confirmation-protocol.test.ts | 4 +- src/main/daemon/daemon-hello-protocol.ts | 20 + src/main/daemon/daemon-idle-shutdown.test.ts | 728 +++++++++++++++ src/main/daemon/daemon-init.test.ts | 761 ++++++++++++++- src/main/daemon/daemon-init.ts | 878 ++++++++++++------ src/main/daemon/daemon-main.ts | 17 +- src/main/daemon/daemon-pty-adapter.ts | 90 +- .../daemon-self-retirement-respawn.test.ts | 228 +++++ src/main/daemon/daemon-server.test.ts | 95 +- src/main/daemon/daemon-server.ts | 492 ++++++++-- src/main/daemon/daemon-spawner.test.ts | 67 +- src/main/daemon/daemon-spawner.ts | 98 +- src/main/daemon/production-launcher.test.ts | 215 ++++- src/main/daemon/production-launcher.ts | 188 +++- src/main/daemon/types.ts | 29 +- tests/e2e/daemon-lifecycle-retirement.spec.ts | 241 +++++ tests/e2e/fixtures/daemon-lifecycle-entry.ts | 122 +++ 23 files changed, 4492 insertions(+), 552 deletions(-) create mode 100644 docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md create mode 100644 src/main/daemon/daemon-hello-protocol.ts create mode 100644 src/main/daemon/daemon-idle-shutdown.test.ts create mode 100644 src/main/daemon/daemon-self-retirement-respawn.test.ts create mode 100644 tests/e2e/daemon-lifecycle-retirement.spec.ts create mode 100644 tests/e2e/fixtures/daemon-lifecycle-entry.ts diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs index be920ca7192..974e865722d 100644 --- a/config/scripts/windows-daemon-workspace-close-repro.mjs +++ b/config/scripts/windows-daemon-workspace-close-repro.mjs @@ -30,19 +30,99 @@ function readProtocolVersion() { return Number(match[1]) } +function createStreamSocket(socketPath, token, protocolVersion, clientId, onFailure) { + const socket = connect(socketPath) + let buffer = '' + + return new Promise((resolveStream, rejectStream) => { + let handshakeComplete = false + const rejectHandshake = (error) => { + if (handshakeComplete) { + onFailure(error) + return + } + handshakeComplete = true + clearTimeout(timer) + socket.removeListener('data', onData) + socket.removeListener('error', onError) + socket.removeListener('close', onClose) + socket.destroy() + rejectStream(error) + } + const onError = (error) => rejectHandshake(error) + const onClose = () => rejectHandshake(new Error('Daemon stream socket closed')) + const onData = (chunk) => { + buffer += chunk.toString('utf8') + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const message = JSON.parse(buffer.slice(0, newline)) + if (message.type !== 'hello') { + return + } + if (!message.ok) { + rejectHandshake(new Error(message.error ?? 'Daemon rejected stream hello')) + return + } + handshakeComplete = true + clearTimeout(timer) + socket.removeListener('data', onData) + // Why: drain terminal events even though this lifecycle repro only asserts through RPC. + socket.on('data', () => {}) + resolveStream(socket) + } + const timer = setTimeout( + () => rejectHandshake(new Error('Daemon stream hello timed out')), + requestTimeoutMs + ) + socket.on('error', onError) + socket.on('close', onClose) + socket.on('data', onData) + socket.once('connect', () => { + socket.write( + `${JSON.stringify({ + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'stream' + })}\n` + ) + }) + }) +} + function createRpcClient(socketPath, tokenPath) { const socket = connect(socketPath) + const clientId = randomUUID() + const protocolVersion = readProtocolVersion() + const token = readFileSync(tokenPath, 'utf8').trim() const pending = new Map() let buffer = '' let requestId = 0 + let streamSocket + let connectionError let helloResolve let helloReject + let helloTimer const hello = new Promise((resolveHello, rejectHello) => { - helloResolve = resolveHello - helloReject = rejectHello + helloResolve = () => { + clearTimeout(helloTimer) + resolveHello() + } + helloReject = (error) => { + clearTimeout(helloTimer) + rejectHello(error) + } }) + helloTimer = setTimeout(() => { + helloReject(new Error('Daemon control hello timed out')) + socket.destroy() + }, requestTimeoutMs) const rejectPending = (error) => { + connectionError ??= error helloReject(error) for (const { reject, timer } of pending.values()) { clearTimeout(timer) @@ -82,25 +162,40 @@ function createRpcClient(socketPath, tokenPath) { } }) - const connected = new Promise((resolveConnected, rejectConnected) => { + const socketConnected = new Promise((resolveConnected, rejectConnected) => { socket.once('connect', resolveConnected) socket.once('error', rejectConnected) - }).then(() => { - socket.write( - `${JSON.stringify({ - type: 'hello', - version: readProtocolVersion(), - token: readFileSync(tokenPath, 'utf8').trim(), - clientId: randomUUID(), - role: 'control' - })}\n` + }) + const connected = Promise.all([ + socketConnected.then(() => { + socket.write( + `${JSON.stringify({ + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'control' + })}\n` + ) + }), + hello + ]).then(async () => { + // Why: v24 only admits terminals for the same complete control+stream pair as production. + streamSocket = await createStreamSocket( + socketPath, + token, + protocolVersion, + clientId, + rejectPending ) - return hello }) return { async request(type, payload) { await connected + if (connectionError) { + throw connectionError + } const id = `repro-${++requestId}` return new Promise((resolveRequest, rejectRequest) => { const timer = setTimeout(() => { @@ -112,6 +207,7 @@ function createRpcClient(socketPath, tokenPath) { }) }, close() { + streamSocket?.destroy() socket.destroy() } } diff --git a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md new file mode 100644 index 00000000000..104f6409929 --- /dev/null +++ b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md @@ -0,0 +1,279 @@ +# Daemon lifecycle retirement for issue #9138 + +## Status + +Implemented and locally validated for PR #9277. + +The earlier full ownership/audit prototype is preserved at: + +- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot` +- commit: `7c915909bd26670b8af36aa683cff85077395dd1` +- GitHub: + +That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup +audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future +legacy enforcement. None of those systems ship in this PR. + +Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was +in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation +alongside v22 and older versions. + +## Inputs and scope decision + +Issue: + +Reviewed design comment by AmethystLiang: + + +The reviewed comment correctly identifies two different problems: + +1. current-generation daemons have no daemon-owned empty lifecycle; +2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act. + +This PR solves only the first problem. The second problem is much larger, adds steady-state +persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement. +Keeping it out makes the user-visible fix small enough to review and benchmark independently. + +The narrow implementation retains these important rules from AmethystLiang's design: + +- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it; +- live sessions always win over cleanup; +- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence; +- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation; +- the daemon, not app startup, makes the atomic empty decision; +- pre-v24 daemons stay reattachable and are not automatically shut down; +- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged. + +The narrow implementation changes one policy from the original comment: it does not use a blanket +30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle +event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime +inactivity heuristics and periodic ownership work. + +## User-visible behavior + +```text +Before + +app disconnects ──> daemon stays forever + ├── live sessions stay (wanted) + └── zero sessions also stay (leak) + +After + +clean app detach ──> daemon atomically checks itself + ├── any live session/work/client ──> stay alive + └── exactly empty ──> exit immediately + +unexpected drop ──> daemon atomically checks itself + ├── live session/work/connection ──> stay alive + └── exactly empty ──> exit immediately + +v23 and older ──> existing reattach behavior; no automatic retirement +``` + +An end user with live terminals should notice no change. An end user who quits with no daemon-backed +terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket, +live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later +app restart launches a fresh daemon instead of reusing an empty process. + +## Protocol and lifecycle design + +### Endpoint identity + +Protocol v24 hello responses include: + +```ts +type DaemonEndpointIdentity = { + pid: number + startedAtMs: number + launchNonce: string +} +``` + +The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes +the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client +sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps +the previous identity-free handshake. + +PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a +write failure terminates the new child and fails launch instead of leaving an untracked daemon. + +### Clean detach + +At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a +v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared +250 ms budget. v23 and older adapters skip it. + +Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is +opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still +receive clean retirement on quit. If startup fallback has already won, the late daemon is not +installed; it instead receives the same bounded retirement attempt, which an adopted live session +will reject. + +The daemon accepts retirement only when, in one event-loop turn: + +- the requesting authenticated client has both control and stream sockets; +- it is the only authenticated client; +- every accepted transport belongs to that client; +- no `createOrAttach` operation is in flight; +- the terminal host has zero sessions. + +When all conditions hold, the daemon synchronously closes the listening server before replying. That +is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then +runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to +the same event-driven empty check when the authenticated sockets close. + +### Initial adoption watchdog + +A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated +client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the +normal launch handoff before the parent could connect; without a bound, a parent crash during that +handoff would orphan the new daemon forever. + +A complete pair permanently cancels this watchdog, and terminal admission requires that complete +pair. Raw and partial transports pause it without extending its original deadline. It is never +rearmed after adoption and is not a terminal inactivity or crash-reconnect timer. + +### Unexpected disconnect + +When the last client that completed both authenticated sockets loses its control connection, the +daemon records an event-driven retirement request with no wall-clock grace. + +- A complete authenticated reconnect cancels the request if existing work or a transport kept the + daemon alive long enough to reconnect. +- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request + before admission can begin. +- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but + cannot erase evidence that the last fully connected app left. +- Replacing a client ID first records the old full connection's loss; completing the replacement + stream cancels that evidence, while an incomplete replacement only blocks retirement. +- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately + rechecks every guard and retires only if it is then exactly empty. + +The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the +token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is +not broadened into destructive or respawn authority. + +### Artifact cleanup + +The daemon removes only artifacts it can claim as its own: + +- token contents must match the daemon's in-memory token; +- PID and launch nonce must match the daemon process and launch nonce; +- cleanup first renames the canonical entry to a unique claim, validates that claim, and never + overwrites or unlinks a replacement installed at the canonical path. + +Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24 +PID/socket artifacts. Legacy cleanup behavior is unchanged. + +## Performance design + +There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or +steady-state persistence write in this PR. + +The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one +two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its +connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection +attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect +bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one +unref'ed startup watchdog, which is canceled permanently on adoption. + +Validation compares current main and the branch for: + +- daemon connect plus two-socket hello latency; +- repeated `listSessions` RPC latency; +- terminal echo/stream throughput through a real socket daemon; +- clean empty-detach latency; +- event-loop delay under repeated RPC/stream work; +- idle CPU/RSS and timer count where observable. + +The acceptance target is no statistically meaningful terminal throughput regression and no new +steady-state disk writes. Results are recorded in the PR body. + +### Local performance regression screen + +The final local host was not quiet enough for publication-grade absolute numbers: load averages were +17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired +same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24 +branch samples; medians across sample medians were: + +| Measure | `main` v23 | branch v24 | +| -------------------- | ---------: | ---------: | +| two-socket connect | 1.38 ms | 1.32 ms | +| `listSessions` RPC | 0.0366 ms | 0.0374 ms | +| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s | + +All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and +event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression +screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work +runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY +byte and no steady-state persistence or polling. + +## Verification and validation + +### Focused unit and integration tests + +- v24 requires valid matching endpoint identity on both sockets. +- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols. +- production launch passes PID path and nonce and writes the exact readiness identity. +- incomplete readiness identity or failed exclusive PID publication kills and rejects the child. +- clean empty detach exits immediately. +- a never-used current adapter connects and retires cleanly on quit. +- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old + deadline. +- a live session, another client, raw transport, or in-flight admission rejects clean retirement. +- a control-only overlapping client blocks but cannot erase the last full-client retirement request. +- a same-client-ID control replacement cannot erase the prior full connection's retirement request. +- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed + request. +- startup fail-open performs bounded empty retirement without installing a late provider. +- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later. +- the synchronous listener fence rejects post-fence connections as retryable. +- an unexpected empty disconnect retires immediately without a runtime inactivity timer. +- a real reconnect cancels pending retirement while live work keeps the daemon available. +- raw and health probes block but cannot erase pending retirement. +- final session exit triggers an immediate guarded retirement check. +- token/PID cleanup preserves malformed, stale, and replacement artifacts. +- authenticated token disappearance performs one coalesced respawn; initial token absence does not. + +### Process/E2E tests + +- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts; +- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts + exit; +- prove a live session rejects retirement and remains reattachable; +- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable; +- never target a production runtime directory or signal a process not created by the fixture. + +### Repository validation + +- Node typecheck; +- oxlint and repository max-lines policy; +- focused daemon, adapter, launcher, restart, and legacy-routing suites; +- full daemon test suite; +- desktop and web production builds; +- `git diff --check` and review of every changed file against `origin/main`; +- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`; +- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication. + +Final local results on macOS arm64 after the event-driven policy revision: + +- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped; +- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned + artifacts retired immediately after its final authenticated client disconnected; +- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed; +- full desktop, web, and native production build passed with existing build warnings; +- three independent post-revision review tracks covering architecture/state machines, + ownership/adoption, and process lifecycle ended clean after actionable findings were fixed. + +The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed +successfully, and repository CI remains responsible for the supported Node/platform matrix. + +## Rollout and rollback + +This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves +v24 as another legacy generation and does not give an older app authority to shut it down. + +If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated +ownership/audit prototype remains recoverable from the archived branch and commit above; it should +return only as a separately reviewed, benchmarked follow-up. diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 25aaebe164f..a3d6d9f610d 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -68,6 +68,12 @@ describe('DaemonClient', () => { onStreamHello?: (msg: HelloMessage) => void rejectVersion?: boolean suppressHelloResponse?: boolean + omitHelloIdentity?: boolean + helloIdentity?: (role: 'control' | 'stream') => { + pid: number + startedAtMs: number + launchNonce: string + } }): Promise { return new Promise((resolve) => { server = createServer((socket) => { @@ -103,7 +109,19 @@ describe('DaemonClient', () => { socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' })) return } - socket.write(encodeNdjson({ type: 'hello', ok: true })) + socket.write( + encodeNdjson({ + type: 'hello', + ok: true, + ...(!opts?.omitHelloIdentity + ? { + daemonIdentity: opts?.helloIdentity + ? opts.helloIdentity(hello.role) + : { pid: 123, startedAtMs: 456, launchNonce: 'default-launch' } + } + : {}) + }) + ) if (hello.role === 'stream') { opts?.onStreamHello?.(hello) } @@ -136,6 +154,48 @@ describe('DaemonClient', () => { await waitFor(() => hellos.length > 0) }) + it('captures one matching endpoint identity from both authenticated sockets', async () => { + const identity = { pid: 123, startedAtMs: 456, launchNonce: 'launch-a' } + await startMockDaemon({ helloIdentity: () => identity }) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + expect(client.getDaemonIdentity()).toEqual(identity) + }) + + it('rejects a v24 daemon that omits endpoint identity', async () => { + await startMockDaemon({ omitHelloIdentity: true }) + client = new DaemonClient({ socketPath, tokenPath }) + + await expect(client.ensureConnected()).rejects.toThrow('Invalid daemon identity') + }) + + it('allows a legacy v23 daemon to omit endpoint identity', async () => { + await startMockDaemon({ omitHelloIdentity: true }) + client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 23 }) + + await expect(client.ensureConnected()).resolves.toBeUndefined() + expect(client.getDaemonIdentity()).toBeNull() + }) + + it('rejects when control and stream sockets report different daemon identities', async () => { + await startMockDaemon({ + helloIdentity: (role) => ({ + pid: role === 'control' ? 123 : 124, + startedAtMs: 456, + launchNonce: 'launch-a' + }) + }) + + client = new DaemonClient({ socketPath, tokenPath }) + + await expect(client.ensureConnected()).rejects.toThrow( + 'Daemon identity changed during connection' + ) + expect(client.getDaemonIdentity()).toBeNull() + }) + it('removes socket startup listeners after connecting', async () => { await startMockDaemon() @@ -189,6 +249,35 @@ describe('DaemonClient', () => { } }) + it('bounds a waiter on an existing connection attempt and prevents socket resurrection', async () => { + let resolveHello: () => void = () => {} + const helloReceived = new Promise((resolve) => { + resolveHello = resolve + }) + await startMockDaemon({ + suppressHelloResponse: true, + onHello: resolveHello + }) + client = new DaemonClient({ socketPath, tokenPath }) + const ownerAttempt = client.ensureConnected().catch((error: Error) => error) + await helloReceived + + await expect(client.ensureConnectedWithin(25)).rejects.toThrow( + 'Connection attempt wait timed out' + ) + client.disconnect() + await expect(ownerAttempt).resolves.toBeInstanceOf(Error) + await new Promise((resolve) => setTimeout(resolve, 25)) + + const disconnected = client as unknown as { + controlSocket: Socket | null + streamSocket: Socket | null + } + expect(client.isConnected()).toBe(false) + expect(disconnected.controlSocket).toBeNull() + expect(disconnected.streamSocket).toBeNull() + }) + it('removes hello startup listeners after timeout', async () => { vi.useFakeTimers() @@ -200,11 +289,16 @@ describe('DaemonClient', () => { socket.destroy = destroy as unknown as Socket['destroy'] const sendHello = ( client as unknown as { - sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise + sendHello( + socket: Socket, + token: string, + role: 'control' | 'stream', + timeoutMs: number + ): Promise } ).sendHello.bind(client) - const promise = sendHello(socket, 'test-token-123', 'control') + const promise = sendHello(socket, 'test-token-123', 'control', 5000) const rejection = expect(promise).rejects.toThrow('Hello response timed out') await vi.advanceTimersByTimeAsync(5000) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 8b6c1b29d90..d0dd2453471 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -4,11 +4,23 @@ import { readFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' -import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types' -import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types' +import { + CLEAN_DISCONNECT_PROTOCOL_VERSION, + PROTOCOL_VERSION, + NOTIFY_PREFIX, + DaemonProtocolError +} from './types' +import type { + DaemonEndpointIdentity, + HelloMessage, + HelloResponse, + RpcResponse, + DaemonEvent +} from './types' import { addNodePtyRecoveryHint } from './node-pty-error-hints' const CONNECT_TIMEOUT_MS = 5000 +const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 const REQUEST_TIMEOUT_MS = 30000 export type DaemonClientOptions = { @@ -42,6 +54,9 @@ export class DaemonClient { // all call ensureConnected(). Without a lock, each starts a separate // connection attempt, overwriting sockets and triggering "Connection lost". private connectingPromise: Promise | null = null + private connectionAttemptGeneration = 0 + private daemonIdentity: DaemonEndpointIdentity | null = null + private observedAuthenticatedDisconnect = false private pendingRequests = new Map() private eventListeners: ((event: unknown) => void)[] = [] @@ -59,15 +74,38 @@ export class DaemonClient { return this.connected } + getDaemonIdentity(): DaemonEndpointIdentity | null { + return this.daemonIdentity ? { ...this.daemonIdentity } : null + } + + hasObservedAuthenticatedDisconnect(): boolean { + return this.observedAuthenticatedDisconnect + } + async ensureConnected(): Promise { + return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false) + } + + async ensureConnectedWithin(timeoutMs: number): Promise { + return this.ensureConnectedWithTimeout(timeoutMs, true) + } + + private async ensureConnectedWithTimeout( + timeoutMs: number, + sharedBudget: boolean + ): Promise { if (this.connected) { return } if (this.connectingPromise) { - return this.connectingPromise + // Why: a normal connection may legitimately consume one timeout for each + // socket and hello; bounded teardown calls instead keep their one shared budget. + const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECTION_ATTEMPT_WAIT_MS + return this.waitForConnectionAttempt(this.connectingPromise, waiterTimeoutMs) } - this.connectingPromise = this.doConnect() + const attemptGeneration = this.connectionAttemptGeneration + this.connectingPromise = this.doConnect(timeoutMs, attemptGeneration, sharedBudget) try { await this.connectingPromise } finally { @@ -75,8 +113,15 @@ export class DaemonClient { } } - private async doConnect(): Promise { + private async doConnect( + timeoutMs: number, + attemptGeneration: number, + sharedBudget: boolean + ): Promise { const token = readFileSync(this.tokenPath, 'utf-8').trim() + const deadlineMs = Date.now() + timeoutMs + const remainingMs = (): number => + sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs const pendingListenerCleanups: (() => void)[] = [] const cleanupPendingListeners = (): void => { for (const cleanup of pendingListenerCleanups.splice(0)) { @@ -86,15 +131,32 @@ export class DaemonClient { try { // Sequential: control first, then stream - this.controlSocket = await this.connectSocket() - await this.sendHello(this.controlSocket, token, 'control') + const pendingControlSocket = await this.connectSocket(remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket) + this.controlSocket = pendingControlSocket + const controlIdentity = await this.sendHello( + this.controlSocket, + token, + 'control', + remainingMs() + ) + this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket) pendingListenerCleanups.push(this.setupControlParser(this.controlSocket)) - this.streamSocket = await this.connectSocket() - await this.sendHello(this.streamSocket, token, 'stream') + const pendingStreamSocket = await this.connectSocket(remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket) + this.streamSocket = pendingStreamSocket + const streamIdentity = await this.sendHello(this.streamSocket, token, 'stream', remainingMs()) + this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket) + if (!sameDaemonIdentity(controlIdentity, streamIdentity)) { + throw new DaemonProtocolError('Daemon identity changed during connection') + } pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket)) + this.assertConnectionAttemptCurrent(attemptGeneration) this.connected = true + this.observedAuthenticatedDisconnect = false + this.daemonIdentity = controlIdentity this.disconnectArmed = true this.connectionGeneration++ @@ -120,12 +182,17 @@ export class DaemonClient { this.controlSocket = null this.streamSocket = null this.connected = false + this.daemonIdentity = null this.disconnectArmed = false throw error } } - async request(type: string, payload: unknown): Promise { + async request( + type: string, + payload: unknown, + timeoutMs = REQUEST_TIMEOUT_MS + ): Promise { if (!this.connected || !this.controlSocket) { throw new DaemonProtocolError('Not connected') } @@ -136,8 +203,8 @@ export class DaemonClient { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRequests.delete(id) - reject(new DaemonProtocolError(`Request ${type} timed out after ${REQUEST_TIMEOUT_MS}ms`)) - }, REQUEST_TIMEOUT_MS) + reject(new DaemonProtocolError(`Request ${type} timed out after ${timeoutMs}ms`)) + }, timeoutMs) this.pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, @@ -180,7 +247,9 @@ export class DaemonClient { } disconnect(): void { + this.connectionAttemptGeneration++ this.connected = false + this.daemonIdentity = null this.disconnectArmed = false this.cleanupActiveSocketListeners() @@ -196,7 +265,7 @@ export class DaemonClient { this.streamSocket = null } - private connectSocket(): Promise { + private connectSocket(timeoutMs: number): Promise { return new Promise((resolve, reject) => { const socket = connect(this.socketPath) const cleanup = (): void => { @@ -216,14 +285,45 @@ export class DaemonClient { cleanup() socket.destroy() reject(new DaemonProtocolError('Connection timed out')) - }, CONNECT_TIMEOUT_MS) + }, timeoutMs) socket.on('connect', onConnect) socket.on('error', onError) }) } - private sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise { + private waitForConnectionAttempt(attempt: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new DaemonProtocolError('Connection attempt wait timed out')) + }, timeoutMs) + attempt.then( + () => { + clearTimeout(timer) + resolve() + }, + (error) => { + clearTimeout(timer) + reject(error) + } + ) + }) + } + + private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Socket): void { + if (attemptGeneration === this.connectionAttemptGeneration) { + return + } + socket?.destroy() + throw new DaemonProtocolError('Disconnected') + } + + private sendHello( + socket: Socket, + token: string, + role: 'control' | 'stream', + timeoutMs: number + ): Promise { return new Promise((resolve, reject) => { const hello: HelloMessage = { type: 'hello', @@ -245,7 +345,7 @@ export class DaemonClient { socket.removeListener('error', onError) socket.removeListener('close', onClose) } - const finish = (error?: Error): void => { + const finish = (error?: Error, identity: DaemonEndpointIdentity | null = null): void => { if (settled) { return } @@ -255,7 +355,7 @@ export class DaemonClient { reject(error) return } - resolve() + resolve(identity) } // Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes. // Decoding each Buffer independently would permanently inject U+FFFD. @@ -271,7 +371,15 @@ export class DaemonClient { try { const response = JSON.parse(line) as HelloResponse if (response.ok) { - finish() + const identity = parseDaemonEndpointIdentity(response.daemonIdentity) + if ( + (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION && identity === null) || + (response.daemonIdentity !== undefined && identity === null) + ) { + finish(new DaemonProtocolError('Invalid daemon identity')) + return + } + finish(undefined, identity) } else { finish( new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected')) @@ -290,7 +398,7 @@ export class DaemonClient { // without a handshake timeout, startup waits forever on ensureConnected(). finish(new DaemonProtocolError('Hello response timed out')) socket.destroy() - }, CONNECT_TIMEOUT_MS) + }, timeoutMs) socket.on('data', onData) socket.on('error', onError) socket.on('close', onClose) @@ -352,7 +460,12 @@ export class DaemonClient { return } this.disconnectArmed = false + this.connectionAttemptGeneration++ + if (this.daemonIdentity) { + this.observedAuthenticatedDisconnect = true + } this.connected = false + this.daemonIdentity = null this.cleanupActiveSocketListeners() for (const [id, pending] of this.pendingRequests) { @@ -377,3 +490,40 @@ export class DaemonClient { cleanup?.() } } + +function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | null { + if (!value || typeof value !== 'object') { + return null + } + const identity = value as { pid?: unknown; startedAtMs?: unknown; launchNonce?: unknown } + if ( + !Number.isSafeInteger(identity.pid) || + (identity.pid as number) <= 0 || + typeof identity.startedAtMs !== 'number' || + !Number.isFinite(identity.startedAtMs) || + identity.startedAtMs <= 0 || + typeof identity.launchNonce !== 'string' || + identity.launchNonce.length === 0 + ) { + return null + } + return { + pid: identity.pid as number, + startedAtMs: identity.startedAtMs, + launchNonce: identity.launchNonce + } +} + +function sameDaemonIdentity( + left: DaemonEndpointIdentity | null, + right: DaemonEndpointIdentity | null +): boolean { + return ( + (left === null && right === null) || + (left !== null && + right !== null && + left.pid === right.pid && + left.startedAtMs === right.startedAtMs && + left.launchNonce === right.launchNonce) + ) +} diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index e24f1c8b3fe..deedb82a2c8 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -51,6 +51,49 @@ describe('daemon-entry parseArgs', () => { }) }) + it('parses the internal PID-record ownership pair', () => { + expect( + parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--pid-record', + '/tmp/t.pid', + '--launch-nonce', + 'launch-a' + ]) + ).toEqual({ + socketPath: '/tmp/t.sock', + tokenPath: '/tmp/t.token', + pidPath: '/tmp/t.pid', + launchNonce: 'launch-a' + }) + }) + + it('rejects either PID-record ownership argument without its pair', () => { + expect(() => + parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--pid-record', + '/tmp/t.pid' + ]) + ).toThrow('provided together') + expect(() => + parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--launch-nonce', + 'launch-a' + ]) + ).toThrow('provided together') + }) + it('still requires --socket and --token when --log-file is given', () => { expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:') }) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 4d5207276e0..12c5ad8dd29 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -17,6 +17,8 @@ import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell' export type ParsedDaemonArgs = { socketPath: string tokenPath: string + pidPath?: string + launchNonce?: string /** Optional — absent for adopted old daemons and tests, which log nothing. */ logFilePath?: string } @@ -25,6 +27,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { let socketPath = '' let tokenPath = '' let logFilePath = '' + let pidPath = '' + let launchNonce = '' for (let i = 0; i < argv.length; i++) { if (argv[i] === '--socket' && argv[i + 1]) { @@ -36,6 +40,12 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { } else if (argv[i] === '--log-file' && argv[i + 1]) { logFilePath = argv[i + 1] i++ + } else if (argv[i] === '--pid-record' && argv[i + 1]) { + pidPath = argv[i + 1] + i++ + } else if (argv[i] === '--launch-nonce' && argv[i + 1]) { + launchNonce = argv[i + 1] + i++ } } @@ -43,7 +53,16 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { throw new Error('Usage: daemon-entry --socket --token [--log-file ]') } - return logFilePath ? { socketPath, tokenPath, logFilePath } : { socketPath, tokenPath } + if ((pidPath && !launchNonce) || (!pidPath && launchNonce)) { + throw new Error('Daemon PID record path and launch nonce must be provided together') + } + + return { + socketPath, + tokenPath, + ...(pidPath ? { pidPath, launchNonce } : {}), + ...(logFilePath ? { logFilePath } : {}) + } } async function main(): Promise { @@ -54,7 +73,10 @@ async function main(): Promise { // an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only. process.stderr.on('error', () => {}) - const { socketPath, tokenPath, logFilePath } = parseArgs(process.argv.slice(2)) + const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs( + process.argv.slice(2) + ) + const startedAtMs = Date.now() - process.uptime() * 1000 // Fail-open: a broken log path must never block daemon startup. const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog() daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath }) @@ -126,16 +148,25 @@ async function main(): Promise { daemon = await startDaemon({ socketPath, tokenPath, + ...(pidPath ? { pidPath } : {}), + ...(launchNonce ? { launchNonce } : {}), + ...(pidPath ? { startedAtMs } : {}), log: daemonLog, preparePtySpawn: prepareMacosTccLoginShell, - spawnSubprocess: (opts) => createPtySubprocess(opts) + spawnSubprocess: (opts) => createPtySubprocess(opts), + onIdleShutdown: () => { + shuttingDown = true + daemonLog.log('shutdown', { reason: 'idle' }) + daemonLog.close() + process.exit(0) + } }) // Signal readiness to parent via IPC (if available) if (process.send) { // Why: Windows has no cheap OS query for a child's start time, so the // daemon self-reports it here for the pid file's pid-recycling guard. - process.send({ type: 'ready', startedAtMs: Date.now() - process.uptime() * 1000 }) + process.send({ type: 'ready', startedAtMs }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 4fbb3047d6a..028fb8f0c4c 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,9 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBeGreaterThan(19) + expect(PROTOCOL_VERSION).toBe(24) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) }) }) diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts new file mode 100644 index 00000000000..4e288e4db9b --- /dev/null +++ b/src/main/daemon/daemon-hello-protocol.ts @@ -0,0 +1,20 @@ +export type HelloMessage = { + type: 'hello' + version: number + token: string + clientId: string + role: 'control' | 'stream' +} + +export type DaemonEndpointIdentity = { + pid: number + startedAtMs: number + launchNonce: string +} + +export type HelloResponse = { + type: 'hello' + ok: boolean + error?: string + daemonIdentity?: DaemonEndpointIdentity +} diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts new file mode 100644 index 00000000000..fa12ba50034 --- /dev/null +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -0,0 +1,728 @@ +import { EventEmitter } from 'node:events' +import { connect, type Socket } from 'node:net' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { PROTOCOL_VERSION } from './types' +import { + getDaemonPidPath, + getDaemonSocketPath, + serializeDaemonPidFile, + unlinkOwnedDaemonPidFile +} from './daemon-spawner' +import type { SubprocessHandle } from './session' + +type ManualTimer = { + callback: () => void + dueAt: number + cancelled: boolean +} + +class ManualIdleClock { + private nowMs = 0 + private timers = new Set() + + setTimeout(callback: () => void, delayMs: number): ManualTimer { + const timer = { callback, dueAt: this.nowMs + delayMs, cancelled: false } + this.timers.add(timer) + return timer + } + + clearTimeout(handle: unknown): void { + const timer = handle as ManualTimer + timer.cancelled = true + this.timers.delete(timer) + } + + now(): number { + return this.nowMs + } + + advanceBy(ms: number): void { + this.nowMs += ms + for (const timer of [...this.timers].sort((a, b) => a.dueAt - b.dueAt)) { + if (timer.cancelled || timer.dueAt > this.nowMs) { + continue + } + this.timers.delete(timer) + timer.callback() + } + } + + get pendingCount(): number { + return this.timers.size + } +} + +function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } { + let onExit: ((code: number) => void) | null = null + return { + pid: 9345, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + forceKill: vi.fn(), + signal: vi.fn(), + onData: vi.fn(), + onExit(callback) { + onExit = callback + }, + dispose: vi.fn(), + exit(code) { + onExit?.(code) + } + } +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000 + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for daemon idle state') + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +async function requestOnRawSocket( + socket: Socket, + request: { id: string; type: string; payload: unknown } +): Promise<{ error?: string }> { + return new Promise((resolve, reject) => { + let buffer = '' + const cleanup = (): void => { + clearTimeout(timeout) + socket.off('data', onData) + } + const onData = (chunk: Buffer): void => { + buffer += chunk.toString('utf8') + for (;;) { + const newlineIndex = buffer.indexOf('\n') + if (newlineIndex === -1) { + return + } + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) + const message = JSON.parse(line) as { id?: string; error?: string } + if (message.id === request.id) { + cleanup() + resolve(message) + return + } + } + } + const timeout = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for raw response ${request.id}`)) + }, 2_000) + socket.on('data', onData) + socket.write(`${JSON.stringify(request)}\n`) + }) +} + +describe('current daemon lifecycle retirement', () => { + let dir: string + let socketPath: string + let tokenPath: string + let pidPath: string + let clock: ManualIdleClock + let server: DaemonServer | null + let subprocess: ReturnType + let onIdleShutdown: ReturnType void>> + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-idle-shutdown-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + pidPath = getDaemonPidPath(dir) + clock = new ManualIdleClock() + subprocess = createMockSubprocess() + onIdleShutdown = vi.fn<() => void>() + server = null + }) + + afterEach(async () => { + await server?.shutdown().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + }) + + async function startServer( + options: { + launchNonce?: string + protocolVersion?: number + } = {} + ): Promise { + server = new DaemonServer({ + socketPath, + tokenPath, + ...(options.launchNonce ? { pidPath, launchNonce: options.launchNonce } : {}), + ...(options.protocolVersion !== undefined + ? { protocolVersion: options.protocolVersion } + : {}), + initialAdoptionTestConfig: { timeoutMs: 100, clock }, + onIdleShutdown, + spawnSubprocess: () => subprocess + }) + await server.start() + } + + it('retires immediately after an unexpected empty disconnect and removes owned artifacts', async () => { + const launchNonce = 'launch-a' + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce }) + ) + await startServer({ launchNonce }) + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + client.disconnect() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + + expect(clock.pendingCount).toBe(0) + expect(existsSync(tokenPath)).toBe(false) + expect(existsSync(pidPath)).toBe(false) + if (process.platform !== 'win32') { + expect(existsSync(socketPath)).toBe(false) + } + }) + + it('retires a fresh daemon that is never adopted by a full client pair', async () => { + await startServer() + + expect(clock.pendingCount).toBe(1) + clock.advanceBy(100) + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('retires immediately after an authenticated clean disconnect proves it is empty', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: true + }) + client.disconnect() + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('keeps resources alive until the shutdownIfIdle reply write flushes', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as { + clients: Map + host: { dispose: () => Promise } + } + const controlSocket = [...daemon.clients.values()][0].controlSocket + const originalWrite = controlSocket.write.bind(controlSocket) + let replyFlushed: (() => void) | undefined + vi.spyOn(controlSocket, 'write').mockImplementation((( + chunk: string | Uint8Array, + ...args: unknown[] + ) => { + replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined + return originalWrite(chunk) + }) as unknown as Socket['write']) + const dispose = vi.spyOn(daemon.host, 'dispose') + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: true }) + expect(dispose).not.toHaveBeenCalled() + expect(onIdleShutdown).not.toHaveBeenCalled() + + replyFlushed?.() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(dispose).toHaveBeenCalledOnce() + client.disconnect() + }) + + it('finishes shutdownIfIdle when the peer closes before its reply callback', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as { + clients: Map + host: { dispose: () => Promise } + } + const controlSocket = [...daemon.clients.values()][0].controlSocket + const originalWrite = controlSocket.write.bind(controlSocket) + vi.spyOn(controlSocket, 'write').mockImplementation(((chunk: string | Uint8Array) => + originalWrite(chunk)) as unknown as Socket['write']) + const dispose = vi.spyOn(daemon.host, 'dispose') + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: true }) + expect(dispose).not.toHaveBeenCalled() + client.disconnect() + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('connects and retires a never-used adapter during clean disconnect', async () => { + await startServer() + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + + await adapter.disconnectOnly() + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('preserves a live session after clean detach and retires when that session exits', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + await client.request('createOrAttach', { sessionId: 'preserved', cols: 80, rows: 24 }) + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: false + }) + client.disconnect() + expect(onIdleShutdown).not.toHaveBeenCalled() + subprocess.exit(0) + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('rejects clean retirement while create or attach is in flight', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as { createOrAttachInFlight: number } + daemon.createOrAttachInFlight = 1 + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: false + }) + + expect(onIdleShutdown).not.toHaveBeenCalled() + client.disconnect() + }) + + it('rejects clean retirement when an unknown transport is connected', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const rawSocket = connect(socketPath) + await new Promise((resolve) => rawSocket.once('connect', resolve)) + + await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: false + }) + rawSocket.destroy() + expect(onIdleShutdown).not.toHaveBeenCalled() + client.disconnect() + }) + + it('rejects clean retirement while another authenticated client is connected', async () => { + await startServer() + const first = new DaemonClient({ socketPath, tokenPath }) + const second = new DaemonClient({ socketPath, tokenPath }) + await Promise.all([first.ensureConnected(), second.ensureConnected()]) + + await expect(first.request('shutdownIfIdle', undefined)).resolves.toEqual({ + retiring: false + }) + + expect(onIdleShutdown).not.toHaveBeenCalled() + first.disconnect() + second.disconnect() + }) + + it("preserves another client's live session when failed adoption disconnects", async () => { + await startServer() + const adoptingAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + const liveOwner = new DaemonClient({ socketPath, tokenPath }) + await Promise.all([adoptingAdapter.establishLifecycleLease(), liveOwner.ensureConnected()]) + await liveOwner.request('createOrAttach', { + sessionId: 'owned-by-second-client', + cols: 80, + rows: 24 + }) + + await adoptingAdapter.disconnectOnly() + + expect(onIdleShutdown).not.toHaveBeenCalled() + await expect(liveOwner.request('listSessions', undefined)).resolves.toMatchObject({ + sessions: [expect.objectContaining({ sessionId: 'owned-by-second-client', isAlive: true })] + }) + subprocess.exit(0) + liveOwner.disconnect() + }) + + it('lets an overlapping raw socket block but not erase empty retirement', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const rawSocket = connect(socketPath) + await new Promise((resolve) => rawSocket.once('connect', resolve)) + client.disconnect() + + expect(onIdleShutdown).not.toHaveBeenCalled() + + rawSocket.destroy() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('retires after the last authenticated client disconnects with no sessions', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + expect(clock.pendingCount).toBe(0) + + client.disconnect() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + expect(clock.pendingCount).toBe(0) + }) + + it('lets a complete reconnect cancel retirement while a live session blocks it', async () => { + await startServer() + const first = new DaemonClient({ socketPath, tokenPath }) + await first.ensureConnected() + await first.request('createOrAttach', { sessionId: 'reconnected', cols: 80, rows: 24 }) + first.disconnect() + const daemon = server as unknown as { retirementRequested: boolean } + await waitFor(() => daemon.retirementRequested) + expect(onIdleShutdown).not.toHaveBeenCalled() + + const second = new DaemonClient({ socketPath, tokenPath }) + await second.ensureConnected() + await waitFor(() => !daemon.retirementRequested) + subprocess.exit(0) + expect(onIdleShutdown).not.toHaveBeenCalled() + + second.disconnect() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('cancels the initial-adoption timeout before the adapter creates its first terminal', async () => { + await startServer() + await waitFor(() => clock.pendingCount === 1) + clock.advanceBy(50) + + const adopted = new DaemonPtyAdapter({ socketPath, tokenPath }) + await adopted.establishLifecycleLease() + expect(clock.pendingCount).toBe(0) + + clock.advanceBy(100) + expect(onIdleShutdown).not.toHaveBeenCalled() + await expect( + adopted.spawn({ + sessionId: 'first-after-adoption', + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ id: 'first-after-adoption' }) + subprocess.exit(0) + adopted.dispose() + }) + + it('does not let repeated authenticated control probes extend the startup deadline', async () => { + await startServer() + const healthControl = connect(socketPath) + await new Promise((resolve) => healthControl.once('connect', resolve)) + healthControl.write( + `${JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: 'startup-health-control', + role: 'control' + })}\n` + ) + const daemon = server as unknown as { clients: Map } + await waitFor(() => daemon.clients.has('startup-health-control')) + clock.advanceBy(1_000) + healthControl.destroy() + + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('does not let a control-only create cancel the initial-adoption timeout', async () => { + await startServer() + const control = connect(socketPath) + await new Promise((resolve) => control.once('connect', resolve)) + control.write( + `${JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: 'startup-control-create', + role: 'control' + })}\n` + ) + const daemon = server as unknown as { + clients: Map + retirementRequested: boolean + } + await waitFor(() => daemon.clients.has('startup-control-create')) + const response = await requestOnRawSocket(control, { + id: 'control-only-create', + type: 'createOrAttach', + payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } + }) + expect(response.error).toContain('connection is incomplete') + expect(daemon.retirementRequested).toBe(false) + expect(clock.pendingCount).toBe(0) + + clock.advanceBy(1_000) + expect(onIdleShutdown).not.toHaveBeenCalled() + + control.destroy() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('keeps empty-retirement intent when a control-only client overlaps the last app', async () => { + await startServer() + const paired = new DaemonClient({ socketPath, tokenPath }) + await paired.ensureConnected() + const incomplete = connect(socketPath) + await new Promise((resolve) => incomplete.once('connect', resolve)) + incomplete.write( + `${JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: 'control-only-overlap', + role: 'control' + })}\n` + ) + const daemon = server as unknown as { + clients: Map + retirementRequested: boolean + } + await waitFor(() => daemon.clients.has('control-only-overlap')) + + paired.disconnect() + await waitFor(() => daemon.retirementRequested) + expect(clock.pendingCount).toBe(0) + const response = await requestOnRawSocket(incomplete, { + id: 'overlap-control-create', + type: 'createOrAttach', + payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } + }) + expect(response.error).toContain('connection is incomplete') + expect(daemon.retirementRequested).toBe(true) + + incomplete.destroy() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('keeps retirement intent when a same-client replacement never completes its stream', async () => { + await startServer() + const paired = new DaemonClient({ socketPath, tokenPath }) + await paired.ensureConnected() + const clientId = (paired as unknown as { clientId: string }).clientId + const replacementControl = connect(socketPath) + await new Promise((resolve) => replacementControl.once('connect', resolve)) + replacementControl.write( + `${JSON.stringify({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId, + role: 'control' + })}\n` + ) + const daemon = server as unknown as { + retirementRequested: boolean + } + await waitFor(() => daemon.retirementRequested) + expect(clock.pendingCount).toBe(0) + + replacementControl.destroy() + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('keeps a live session after clients disconnect, then retires on its exit', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + await client.request('createOrAttach', { sessionId: 'live', cols: 80, rows: 24 }) + client.disconnect() + const daemon = server as unknown as { retirementRequested: boolean } + await waitFor(() => daemon.retirementRequested) + + expect(onIdleShutdown).not.toHaveBeenCalled() + expect(clock.pendingCount).toBe(0) + + subprocess.exit(0) + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + }) + + it('uses the direct-construction protocol fixture version for hello compatibility', async () => { + await startServer({ protocolVersion: 22 }) + const client = new DaemonClient({ socketPath, tokenPath, protocolVersion: 22 }) + + await expect(client.ensureConnected()).resolves.toBeUndefined() + client.disconnect() + }) + + it('requests clean retirement only for protocol v24 and newer adapters', async () => { + await startServer() + const current = new DaemonPtyAdapter({ socketPath, tokenPath }) + await current.listProcesses() + const currentClient = ( + current as unknown as { + client: DaemonClient + } + ).client + const currentRequest = vi.spyOn(currentClient, 'request') + + await current.disconnectOnly() + + expect(currentRequest).toHaveBeenCalledOnce() + expect(currentRequest.mock.calls[0]?.slice(0, 2)).toEqual(['shutdownIfIdle', undefined]) + const timeoutMs = currentRequest.mock.calls[0]?.[2] + // Why: connection and RPC share a wall-clock budget, so elapsed setup time is expected. + expect(timeoutMs).toEqual(expect.any(Number)) + expect(timeoutMs).toBeGreaterThan(0) + expect(timeoutMs).toBeLessThanOrEqual(250) + }) + + it('does not send the v24 clean-disconnect RPC to a legacy daemon', async () => { + await startServer({ protocolVersion: 23 }) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 }) + await legacy.listProcesses() + const legacyClient = ( + legacy as unknown as { + client: DaemonClient + } + ).client + const legacyRequest = vi.spyOn(legacyClient, 'request') + + await legacy.disconnectOnly() + + expect(legacyRequest.mock.calls.map(([type]) => type)).not.toContain('shutdownIfIdle') + }) + + it('rejects create or attach once the idle admission fence is pending', async () => { + await startServer() + const daemon = server as unknown as { + idleShutdownState: string + routeRequest(clientId: string, request: unknown): Promise + } + daemon.idleShutdownState = 'idle-shutdown-pending' + + await expect( + daemon.routeRequest('late-client', { + id: 'late-create', + type: 'createOrAttach', + payload: { sessionId: 'late', cols: 80, rows: 24 } + }) + ).rejects.toThrow('temporarily unavailable; reconnect') + }) + + it('aborts the pending shutdown when create or attach started before the fence', async () => { + await startServer() + const daemon = server as unknown as { + createOrAttachInFlight: number + idleShutdownState: string + beginIdleShutdown(): void + } + daemon.createOrAttachInFlight = 1 + + daemon.beginIdleShutdown() + + expect(daemon.idleShutdownState).toBe('running') + expect(onIdleShutdown).not.toHaveBeenCalled() + }) + + it('explicitly marks a post-fence accepted transport as retryable', async () => { + await startServer() + const daemon = server as unknown as { + idleShutdownState: string + handleConnection(socket: Socket): void + } + daemon.idleShutdownState = 'idle-shutdown-pending' + const socket = new EventEmitter() as Socket + socket.end = vi.fn() as unknown as Socket['end'] + + daemon.handleConnection(socket) + + const payload = vi.mocked(socket.end).mock.calls[0]?.[0] + expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true }) + socket.emit('close') + }) + + it('preserves a replacement PID record during otherwise successful idle cleanup', async () => { + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce: 'replacement' }) + ) + await startServer({ launchNonce: 'mine' }) + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + await client.request('shutdownIfIdle', undefined) + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({ + pid: process.pid, + launchNonce: 'replacement' + }) + }) + + it('preserves a token file replaced before idle cleanup', async () => { + await startServer() + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + writeFileSync(tokenPath, 'replacement-token') + + await client.request('shutdownIfIdle', undefined) + await waitFor(() => onIdleShutdown.mock.calls.length === 1) + + expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token') + }) +}) + +describe('daemon PID record ownership cleanup', () => { + let dir: string + let pidPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-pid-ownership-')) + pidPath = join(dir, 'daemon.pid') + }) + + afterEach(() => rmSync(dir, { recursive: true, force: true })) + + it('unlinks only an exact PID and launch nonce match', () => { + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: 123, startedAtMs: null, launchNonce: 'mine' }) + ) + + expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(true) + expect(existsSync(pidPath)).toBe(false) + }) + + it.each([ + ['malformed', '{'], + ['stale PID', serializeDaemonPidFile({ pid: 456, startedAtMs: null, launchNonce: 'mine' })], + [ + 'replacement nonce', + serializeDaemonPidFile({ pid: 123, startedAtMs: null, launchNonce: 'new' }) + ] + ])('preserves a %s record', (_label, contents) => { + writeFileSync(pidPath, contents) + + expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(false) + expect(readFileSync(pidPath, 'utf8')).toBe(contents) + }) + + it('leaves a missing record missing', () => { + expect(unlinkOwnedDaemonPidFile(pidPath, 123, 'mine')).toBe(false) + expect(existsSync(pidPath)).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 89493f675bf..9bc78a4da75 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -38,11 +38,17 @@ const { killStaleDaemonMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, + unlinkOwnedDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, + adoptionLeaseReleases, + lifecycleLeaseErrors, + disconnectOnlyErrors, + routerSubscriptionError, adapterInstances, defaultListSessionsSessions, + listProcessesControl, getLocalPtyProviderMock, localFallbackProvider, setLocalPtyProviderMock, @@ -98,6 +104,7 @@ const { const parseDaemonPidFileMock = vi.fn( (): { pid: number; startedAtMs: number | null } | null => null ) + const unlinkOwnedDaemonPidFileMock = vi.fn(() => true) const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { return { @@ -115,12 +122,19 @@ const { tokenPath: string mode?: 'degraded-new-pty-fallback' }>)[] = [] + const adoptionLeaseReleases: ReturnType[] = [] + const lifecycleLeaseErrors: Error[] = [] + const disconnectOnlyErrors: Error[] = [] + const routerSubscriptionError: { current: Error | null } = { current: null } // Same for DaemonPtyAdapter. The test asserts the replacement adapter is a // fresh instance whose respawn closure targets the *original* spawner. const adapterInstances: MockAdapter[] = [] // Why: adapters are constructed inside initDaemonPtyProvider, so tests that // need listSessions to report live sessions set this before calling init. const defaultListSessionsSessions: { sessionId: string }[] = [] + const listProcessesControl: { + current: null | (() => Promise<{ sessionId: string }[]>) + } = { current: null } const localFallbackProvider = { routesFreshSpawnsToLocalProvider: undefined, @@ -171,11 +185,17 @@ const { killStaleDaemonMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, + unlinkOwnedDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, + adoptionLeaseReleases, + lifecycleLeaseErrors, + disconnectOnlyErrors, + routerSubscriptionError, adapterInstances, defaultListSessionsSessions, + listProcessesControl, getLocalPtyProviderMock, localFallbackProvider, setLocalPtyProviderMock, @@ -205,6 +225,7 @@ type MockAdapter = { fanoutSyntheticExits: ReturnType listProcesses: ReturnType listSessions: ReturnType + establishLifecycleLease: ReturnType shutdown: ReturnType dispose: ReturnType disconnectOnly: ReturnType @@ -261,7 +282,11 @@ vi.mock('./daemon-spawner', () => ({ readonly shutdown: ReturnType readonly getHandle: ReturnType private socketCounter: number - private handle: { mode?: 'degraded-new-pty-fallback'; shutdown: () => Promise } | null + private handle: { + mode?: 'degraded-new-pty-fallback' + releaseAdoptionLease?: () => void + shutdown: () => Promise + } | null constructor(opts: { runtimeDir: string; launcher: unknown }) { this.launcher = opts.launcher this.socketCounter = 0 @@ -273,7 +298,9 @@ vi.mock('./daemon-spawner', () => ({ const override = ensureRunningOverrides.shift() if (override) { const result = await override() - this.handle = { shutdown: vi.fn(async () => {}) } + const releaseAdoptionLease = vi.fn() + adoptionLeaseReleases.push(releaseAdoptionLease) + this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) } if (result.mode) { this.handle.mode = result.mode } @@ -283,7 +310,9 @@ vi.mock('./daemon-spawner', () => ({ } } this.socketCounter += 1 - this.handle = { shutdown: vi.fn(async () => {}) } + const releaseAdoptionLease = vi.fn() + adoptionLeaseReleases.push(releaseAdoptionLease) + this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) } return { socketPath: `/fake/socket-${this.socketCounter}`, tokenPath: `/fake/token-${this.socketCounter}` @@ -301,7 +330,8 @@ vi.mock('./daemon-spawner', () => ({ `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.token`, getDaemonPidPath: (_dir: string, version?: number) => `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`, - serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj) + serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj), + unlinkOwnedDaemonPidFile: unlinkOwnedDaemonPidFileMock })) vi.mock('./daemon-pty-adapter', () => ({ @@ -312,6 +342,7 @@ vi.mock('./daemon-pty-adapter', () => ({ readonly fanoutSyntheticExits: ReturnType readonly listProcesses: ReturnType readonly listSessions: ReturnType + readonly establishLifecycleLease: ReturnType readonly shutdown: ReturnType readonly dispose: ReturnType readonly disconnectOnly: ReturnType @@ -326,12 +357,32 @@ vi.mock('./daemon-pty-adapter', () => ({ this.fanoutSyntheticExits = vi.fn(() => { this.callOrder.push('fanoutSyntheticExits') }) - this.listProcesses = vi.fn(async () => []) + this.listProcesses = vi.fn(async () => + listProcessesControl.current ? listProcessesControl.current() : [] + ) this.listSessions = vi.fn(async () => [...defaultListSessionsSessions]) + const lifecycleLeaseError = lifecycleLeaseErrors.shift() + this.establishLifecycleLease = vi.fn(async () => { + if (lifecycleLeaseError) { + throw lifecycleLeaseError + } + }) this.shutdown = vi.fn(async () => {}) this.dispose = vi.fn() - this.disconnectOnly = vi.fn(async () => {}) - this.onData = vi.fn(() => () => {}) + const disconnectOnlyError = disconnectOnlyErrors.shift() + this.disconnectOnly = vi.fn(async () => { + if (disconnectOnlyError) { + throw disconnectOnlyError + } + }) + this.onData = vi.fn(() => { + if (routerSubscriptionError.current) { + const error = routerSubscriptionError.current + routerSubscriptionError.current = null + throw error + } + return () => {} + }) this.onExit = vi.fn(() => () => {}) adapterInstances.push(this as unknown as MockAdapter) } @@ -349,8 +400,13 @@ async function importFresh() { vi.resetModules() spawnerInstances.length = 0 ensureRunningOverrides.length = 0 + adoptionLeaseReleases.length = 0 + lifecycleLeaseErrors.length = 0 + disconnectOnlyErrors.length = 0 + routerSubscriptionError.current = null adapterInstances.length = 0 defaultListSessionsSessions.length = 0 + listProcessesControl.current = null getLocalPtyProviderMock.mockClear() localFallbackProvider.spawn.mockClear() localFallbackProvider.write.mockClear() @@ -374,7 +430,14 @@ async function importFresh() { forkMock.mockReset() isPackagedMock.mockReset() isPackagedMock.mockReturnValue(false) - daemonClientMock.mockClear() + daemonClientMock.mockReset() + daemonClientMock.mockImplementation(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + } + }) probeSocketExistsMock.mockClear() writeFileSyncMock.mockClear() readFileSyncMock.mockReset() @@ -384,6 +447,8 @@ async function importFresh() { unlinkSyncMock.mockClear() parseDaemonPidFileMock.mockReset() parseDaemonPidFileMock.mockReturnValue(null) + unlinkOwnedDaemonPidFileMock.mockReset() + unlinkOwnedDaemonPidFileMock.mockReturnValue(true) getProcessStartedAtMsMock.mockReset() getProcessStartedAtMsMock.mockReturnValue(1_000_000) // Why: importing daemon-init *after* resetModules means the module-level @@ -393,6 +458,16 @@ async function importFresh() { return import('./daemon-init') } +function mockConnectedAdoptionClientOnce(): void { + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(), + disconnect: vi.fn() + } + }) +} + describe('daemon-init: runRestartDaemon (7-step sequence)', () => { beforeEach(() => { probeSocketExistsMock.mockReturnValue(false) @@ -429,6 +504,50 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(rebindLocalProviderListenersMock.mock.invocationCallOrder[0]).toBeGreaterThan( setLocalPtyProviderMock.mock.invocationCallOrder[0] ) + expect(adapterInstances[0].establishLifecycleLease).toHaveBeenCalledOnce() + expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( + setLocalPtyProviderMock.mock.invocationCallOrder[0] + ) + expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() + expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( + adoptionLeaseReleases[0].mock.invocationCallOrder[0] + ) + }) + + it('uses daemon-owned idle retirement when a fresh launch fails permanent adoption', async () => { + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/launched-socket', + tokenPath: '/fake/launched-token' + })) + lifecycleLeaseErrors.push(new Error('lease identity mismatch')) + + await expect(mod.initDaemonPtyProvider()).rejects.toThrow('lease identity mismatch') + + expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(adapterInstances[0].dispose).not.toHaveBeenCalled() + expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() + expect(adapterInstances[0].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( + adoptionLeaseReleases[0].mock.invocationCallOrder[0] + ) + expect(setLocalPtyProviderMock).not.toHaveBeenCalled() + }) + + it('does not kill a preserved daemon when startup lease acquisition fails', async () => { + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/preserved-socket', + tokenPath: '/fake/preserved-token' + })) + lifecycleLeaseErrors.push(new Error('preserved lease failed')) + + await expect(mod.initDaemonPtyProvider()).rejects.toThrow('preserved lease failed') + + expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce() + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() + expect(setLocalPtyProviderMock).not.toHaveBeenCalled() }) it('prunes seeded Claude live-PTY ids against daemon sessions after init', async () => { @@ -474,12 +593,100 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { resolveEnsureRunning({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' }) await started - expect(adapterInstances).toHaveLength(0) + expect(adapterInstances).toHaveLength(1) + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(adapterInstances[0].establishLifecycleLease).not.toHaveBeenCalled() expect(setLocalPtyProviderMock).not.toHaveBeenCalled() expect(rebindLocalProviderListenersMock).not.toHaveBeenCalled() expect(mod.getDaemonProvider()).toBeNull() }) + it('disconnects uninstalled adapter leases when startup aborts during legacy discovery', async () => { + const mod = await importFresh() + probeSocketExistsMock.mockImplementation((p?: string) => p?.endsWith('daemon-v9.sock') ?? false) + netConnectMock.mockImplementation(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'connect') { + queueMicrotask(() => cb()) + } + return this + }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + destroy() {} + } + }) + let resolveDiscovery!: (sessions: { sessionId: string }[]) => void + const discovery = new Promise<{ sessionId: string }[]>((resolve) => { + resolveDiscovery = resolve + }) + listProcessesControl.current = () => discovery + const abortController = new AbortController() + + const started = mod.initDaemonPtyProvider(abortController.signal) + await vi.waitFor(() => { + expect(adapterInstances.some((instance) => instance.protocolVersion === 9)).toBe(true) + expect( + adapterInstances.some((instance) => instance.listProcesses.mock.calls.length > 0) + ).toBe(true) + }) + abortController.abort() + resolveDiscovery([]) + await started + + expect(adapterInstances).toHaveLength(2) + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() + expect(setLocalPtyProviderMock).not.toHaveBeenCalled() + expect(mod.getDaemonProvider()).toBeNull() + }) + + it('retains every adapter cleanup failure when legacy router setup aborts', async () => { + const mod = await importFresh() + probeSocketExistsMock.mockImplementation( + (path?: string) => path?.endsWith('daemon-v9.sock') ?? false + ) + netConnectMock.mockImplementation(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, callback: () => void) { + handlers[event]?.push(callback) + if (event === 'connect') { + queueMicrotask(() => callback()) + } + return this + }, + removeListener(event: string, callback: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + destroy() {} + } + }) + const discoveryError = new Error('router subscription failed') + const currentCleanupError = new Error('current cleanup failed') + const legacyCleanupError = new Error('legacy cleanup failed') + routerSubscriptionError.current = discoveryError + disconnectOnlyErrors.push(currentCleanupError, legacyCleanupError) + + const error = await mod.initDaemonPtyProvider().catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(AggregateError) + const topLevelErrors = (error as AggregateError).errors + expect(topLevelErrors[0]).toBe(discoveryError) + expect(topLevelErrors[1]).toBeInstanceOf(AggregateError) + expect((topLevelErrors[1] as AggregateError).errors).toEqual([ + legacyCleanupError, + currentCleanupError + ]) + expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce() + expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() + }) + it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ @@ -537,6 +744,26 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(order).toEqual(['fanout', 'unbind']) }) + it('uses daemon-owned idle retirement after a failed manual-restart adoption', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const originalProvider = mod.getDaemonProvider() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/restart-failure-socket', + tokenPath: '/fake/restart-failure-token' + })) + lifecycleLeaseErrors.push(new Error('restart lease failed')) + + await expect(mod.restartDaemon()).rejects.toThrow('restart lease failed') + + expect(adoptionLeaseReleases[1]).toHaveBeenCalledOnce() + expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() + expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled() + expect(mod.getDaemonProvider()).toBe(originalProvider) + expect(unbindLocalProviderListenersMock).toHaveBeenCalledOnce() + expect(rebindLocalProviderListenersMock).toHaveBeenCalledTimes(2) + }) + it('fans exits for preserved degraded current-daemon sessions during restart', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ @@ -801,6 +1028,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // result, so its existence proves the ordering. expect(adapterInstances).toHaveLength(2) expect(adapterInstances[1].options.socketPath).toBe('/fake/socket-2') + expect(adapterInstances[1].establishLifecycleLease).toHaveBeenCalledOnce() + expect(adapterInstances[1].establishLifecycleLease.mock.invocationCallOrder[0]).toBeLessThan( + setLocalPtyProviderMock.mock.invocationCallOrder.at(-1) as number + ) }) it('exercises the alive-daemon cleanup path: issues shutdown RPC via DaemonClient before spawning a replacement', async () => { @@ -820,9 +1051,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) const ensureConnectedMock = vi.fn(async () => {}) const disconnectMock = vi.fn() - // Why: DaemonClient is invoked via `new DaemonClient(...)`, so the mock - // factory must return a constructor-compatible function. Capture the - // existing impl so later tests aren't affected. + const mod = await importFresh() + await mod.initDaemonPtyProvider() daemonClientMock.mockImplementationOnce(function MockDaemonClientForShutdown() { return { ensureConnected: ensureConnectedMock, @@ -831,9 +1061,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) - const mod = await importFresh() - await mod.initDaemonPtyProvider() - // Make probeSocket return true for the current-version path by toggling // both the fs.existsSync proxy AND net.connect resolving "alive". probeSocketExistsMock.mockReturnValue(true) @@ -996,7 +1223,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1036,6 +1263,79 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) }) + it('holds a full adoption pair before a healthy launcher resolves', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const events: string[] = [] + const disconnect = vi.fn() + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => { + events.push('full-pair') + }), + request: vi.fn(), + disconnect + } + }) + checkDaemonHealthMock.mockImplementationOnce(async () => { + events.push('health') + return 'healthy' + }) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ + releaseAdoptionLease?(): void + shutdown(): Promise + }> + + const handle = await launcher('/fake/socket', '/fake/token') + + expect(events[0]).toBe('full-pair') + expect(events.indexOf('full-pair')).toBeLessThan(events.indexOf('health')) + expect(disconnect).not.toHaveBeenCalled() + handle.releaseAdoptionLease?.() + expect(disconnect).toHaveBeenCalledOnce() + }) + + it('disconnects every temporary client when healthy adoption fails', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const initialDisconnect = vi.fn() + const replacementDisconnect = vi.fn() + daemonClientMock + .mockImplementationOnce(function MockInitialAdoptionClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('initial adoption failed') + }), + request: vi.fn(), + disconnect: initialDisconnect + } + }) + .mockImplementationOnce(function MockReplacementAdoptionClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('replacement adoption failed') + }), + request: vi.fn(), + disconnect: replacementDisconnect + } + }) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + + await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( + 'replacement adoption failed' + ) + + expect(initialDisconnect).toHaveBeenCalledOnce() + expect(replacementDisconnect).toHaveBeenCalledOnce() + expect(forkMock).not.toHaveBeenCalled() + }) + it('preserves a daemon launched from another app path when it owns live sessions', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1052,6 +1352,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1091,6 +1392,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1133,7 +1435,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1185,6 +1487,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1220,6 +1523,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1264,7 +1568,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1316,7 +1620,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1336,16 +1640,355 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() - expect(writeFileSyncMock).toHaveBeenCalledWith( - `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, - JSON.stringify({ - pid: 12345, - startedAtMs: 1_000_000, - entryPath: FAKE_DAEMON_ENTRY_PATH, - appVersion: '1.2.3' - }), - { mode: 0o600 } + const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? [] + expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`) + expect(JSON.parse(pidContents as string)).toEqual({ + pid: 12345, + startedAtMs: 1_000_000, + entryPath: FAKE_DAEMON_ENTRY_PATH, + appVersion: '1.2.3', + launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) + }) + expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) + const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[] + const launchNonceIndex = launchArgs.indexOf('--launch-nonce') + expect(launchArgs).toEqual( + expect.arrayContaining([ + '--pid-record', + `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, + '--launch-nonce' + ]) ) + expect(launchArgs[launchNonceIndex + 1]).toBe(JSON.parse(pidContents as string).launchNonce) + }) + + it('keeps a live PID record after adoption failure and removes it on exact child exit', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const adoptionDisconnects: ReturnType[] = [] + function MockFailingAdoptionClient() { + const disconnect = vi.fn() + adoptionDisconnects.push(disconnect) + return { + ensureConnected: vi.fn(async () => { + throw new Error('adoption unavailable') + }), + request: vi.fn(), + disconnect + } + } + for (let index = 0; index < 3; index++) { + daemonClientMock.mockImplementationOnce(MockFailingAdoptionClient) + } + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + on(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + once(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + return this + }, + off(event: string, callback: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ shutdown(): Promise }> + + await expect( + launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-delayed') + ).rejects.toThrow('adoption unavailable') + + expect(writeFileSyncMock).toHaveBeenCalledWith( + '/fake/daemon.pid', + expect.stringContaining('launch-delayed'), + { mode: 0o600, flag: 'wx' } + ) + expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled() + expect(adoptionDisconnects.at(-1)).toHaveBeenCalledOnce() + + child.exitCode = 0 + for (const callback of handlers.exit.slice()) { + callback(0) + } + expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( + '/fake/daemon.pid', + 12345, + 'launch-delayed' + ) + }) + + it('kills and rejects a daemon whose readiness message omits its start time', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) + }) + const child = { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + try { + await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( + 'Daemon readiness identity is incomplete' + ) + expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') + expect(writeFileSyncMock).not.toHaveBeenCalled() + expect(child.disconnect).not.toHaveBeenCalled() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + } + }) + + it('rejects startup cleanup when SIGKILL never produces child exit', async () => { + vi.useFakeTimers() + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + try { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready' })) + } + return this + }, + off(event: string, callback: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + + const launch = launcher('/fake/socket', '/fake/token') + await Promise.resolve() + await Promise.resolve() + const rejection = expect(launch).rejects.toThrow('startup and child cleanup both failed') + await vi.advanceTimersByTimeAsync(6_000) + await rejection + + expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + vi.useRealTimers() + } + }) + + it('surfaces non-ESRCH startup termination errors and releases IPC', async () => { + const signalError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw signalError + }) + try { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on(event: string, callback: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready' })) + } + return this + }, + off: vi.fn(), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + + const error = await launcher('/fake/socket', '/fake/token').catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + signalError + ]) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + } + }) + + it('settles startup with both errors when a malformed-ready child ignores termination', async () => { + vi.useFakeTimers() + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + try { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready' })) + } + return this + }, + off(event: string, callback: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + + const launch = launcher('/fake/socket', '/fake/token').catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(6_000) + const error = await launch + + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + expect.objectContaining({ message: 'Daemon did not exit after SIGKILL' }) + ]) + expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') + expect(handlers.message).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + expect(handlers.exit).toHaveLength(0) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + vi.useRealTimers() + } + }) + + it('kills and rejects a daemon when exclusive PID publication fails', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) + }) + const child = { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + off: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn() + } + const publicationError = Object.assign(new Error('PID record already exists'), { + code: 'EEXIST' + }) + writeFileSyncMock.mockImplementationOnce(() => { + throw publicationError + }) + forkMock.mockReturnValueOnce(child) + + try { + await expect(launcher('/fake/socket', '/fake/token')).rejects.toBe(publicationError) + expect(writeFileSyncMock).toHaveBeenCalledWith( + `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, + expect.any(String), + { mode: 0o600, flag: 'wx' } + ) + expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') + expect(child.disconnect).not.toHaveBeenCalled() + expect(child.unref).toHaveBeenCalledOnce() + } finally { + kill.mockRestore() + } }) it('removes detached daemon startup listeners after startup error', async () => { @@ -1390,7 +2033,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(handlers.error).toHaveLength(0) expect(handlers.exit).toHaveLength(0) expect(child.disconnect).not.toHaveBeenCalled() - expect(child.unref).not.toHaveBeenCalled() + expect(child.unref).toHaveBeenCalledOnce() }) it('captures daemon startup stderr into the failure error', async () => { @@ -1429,6 +2072,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } const child = { pid: 4321, + exitCode: null as number | null, stderr, on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) @@ -1439,6 +2083,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { for (const dataCb of stderrDataCbs.slice()) { dataCb(Buffer.from("Error: Cannot find module 'electron'\n")) } + child.exitCode = 1 cb(1) }) } @@ -1492,10 +2137,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, + once(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + return this + }, off: vi.fn(() => child), disconnect: vi.fn(), unref: vi.fn() @@ -1523,6 +2172,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1557,6 +2207,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } return {} }) + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), @@ -1602,10 +2253,13 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, + once() { + return this + }, off() { return this }, @@ -1700,11 +2354,16 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { disconnect: vi.fn() } } - // Every probe across the whole grace window times out (permanent wedge). + // Every probe across the whole grace window times out (permanent wedge), + // then the freshly spawned daemon accepts the temporary adoption lease. + let daemonClientConstructionCount = 0 daemonClientMock.mockImplementation(function MockDaemonClient() { + daemonClientConstructionCount++ return { ensureConnected: vi.fn(async () => { - throw new Error('Hello response timed out') + if (daemonClientConstructionCount <= 2 + WEDGED_DAEMON_GRACE_RETRIES) { + throw new Error('Hello response timed out') + } }), request: vi.fn(), disconnect: vi.fn() @@ -1722,7 +2381,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1747,7 +2406,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() // The launcher probes the full grace budget before giving up: 1 initial // probe + WEDGED_DAEMON_GRACE_RETRIES retries. - expect(daemonClientMock).toHaveBeenCalledTimes(1 + WEDGED_DAEMON_GRACE_RETRIES) + expect(daemonClientMock).toHaveBeenCalledTimes(3 + WEDGED_DAEMON_GRACE_RETRIES) } finally { // Restore the answering default so the persistent throwing impl above // does not leak into later tests (clearAllMocks clears calls, not impls). @@ -1842,7 +2501,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -1864,8 +2523,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() // Pins the 'rejected' fast-path: a daemon that actively refuses the // handshake is never worth a grace window, so it is probed exactly once - // (no retries) before replacement. - expect(daemonClientMock).toHaveBeenCalledTimes(1) + // (no retries) before replacement. The other clients are the initial + // adoption attempt and the fresh daemon's temporary adoption lease. + expect(daemonClientMock).toHaveBeenCalledTimes(3) }) it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => { @@ -1917,16 +2577,16 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') - expect(writeFileSyncMock).toHaveBeenCalledWith( - `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, - JSON.stringify({ - pid: 12345, - startedAtMs: 1_700_000_123_456, - entryPath: FAKE_DAEMON_ENTRY_PATH, - appVersion: '1.2.3' - }), - { mode: 0o600 } - ) + const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? [] + expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`) + expect(JSON.parse(pidContents as string)).toEqual({ + pid: 12345, + startedAtMs: 1_700_000_123_456, + entryPath: FAKE_DAEMON_ENTRY_PATH, + appVersion: '1.2.3', + launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) + }) + expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) }) it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { @@ -1987,7 +2647,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -2073,7 +2733,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready' })) + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) } return this }, @@ -2126,6 +2786,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { return {} }) const disconnectMock = vi.fn() + mockConnectedAdoptionClientOnce() daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 1bbc2cad9c3..093cb7a00f7 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -7,9 +7,10 @@ files with no cleaner ownership seam: restart, replaceDaemonProvider, and the module-level spawner/adapter singletons must stay co-located so a future change cannot leave them drifting out of sync. */ import { join } from 'node:path' +import { randomUUID } from 'node:crypto' import { app } from 'electron' import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { fork } from 'node:child_process' +import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' import { DaemonSpawner, @@ -17,6 +18,7 @@ import { getDaemonSocketPath, getDaemonTokenPath, serializeDaemonPidFile, + unlinkOwnedDaemonPidFile, type DaemonLauncher, type DaemonProcessHandle } from './daemon-spawner' @@ -24,6 +26,7 @@ import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' import { + CLEAN_DISCONNECT_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, type ListSessionsResult @@ -31,7 +34,6 @@ import { import { getMacDaemonSystemResolverHealth, getDaemonLaunchIdentity, - getProcessStartedAtMs, checkDaemonHealth, isDaemonStaleForCurrentBundle, killStaleDaemon, @@ -82,6 +84,9 @@ function logDaemonMilestone(event: string, details: Record = {} // that takes longer than ~60s to drain is replaced (live processes lost, though // scrollback cold-restores). Raise this only alongside the fail-open cap. export const WEDGED_DAEMON_GRACE_RETRIES = 11 +const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000 +const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 +const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 let spawner: DaemonSpawner | null = null type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider @@ -205,6 +210,134 @@ function createPreservedDaemonHandle( return handle } +async function holdDaemonAdoptionLease( + handle: DaemonProcessHandle, + socketPath: string, + tokenPath: string, + connectedClient?: DaemonClient +): Promise { + const client = connectedClient ?? new DaemonClient({ socketPath, tokenPath }) + try { + await client.ensureConnected() + } catch (error) { + client.disconnect() + throw error + } + handle.releaseAdoptionLease = () => client.disconnect() + return handle +} + +function releaseDaemonAdoptionLease(handle: DaemonProcessHandle | null): void { + takeDaemonAdoptionLeaseRelease(handle)?.() +} + +function takeDaemonAdoptionLeaseRelease( + handle: DaemonProcessHandle | null +): (() => void) | undefined { + const release = handle?.releaseAdoptionLease + if (!release || !handle) { + return undefined + } + delete handle.releaseAdoptionLease + return release +} + +async function cleanupFailedDaemonAdoption( + failedSpawner: DaemonSpawner, + current: DaemonPtyAdapter, + legacy: DaemonPtyAdapter[] = [] +): Promise { + const handle = failedSpawner.getHandle() + const results = await Promise.allSettled([ + Promise.resolve().then(() => releaseDaemonAdoptionLease(handle)), + ...legacy.map((entry) => entry.disconnectOnly()), + (async () => { + try { + // Why: endpoint publication allows other authenticated clients to win; + // only daemon-side shutdownIfIdle may prove a failed adoption is killable. + await current.disconnectOnly() + } catch (error) { + current.dispose() + throw error + } + })() + ]) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length > 0) { + throw new AggregateError(failures, 'Daemon adoption cleanup failed') + } +} + +async function terminateLaunchedDaemonChild(child: ChildProcess): Promise { + try { + if ( + (child.exitCode !== null && child.exitCode !== undefined) || + (child.signalCode !== null && child.signalCode !== undefined) + ) { + return + } + await new Promise((resolve, reject) => { + let gracefulTimer: ReturnType + let forcedTimer: ReturnType | undefined + let settled = false + const finish = (error?: unknown): void => { + if (settled) { + return + } + settled = true + clearTimeout(gracefulTimer) + if (forcedTimer) { + clearTimeout(forcedTimer) + } + child.off('exit', onExit) + if (error) { + reject(error) + } else { + resolve() + } + } + const onExit = (): void => finish() + child.on('exit', onExit) + gracefulTimer = setTimeout(() => { + if (child.pid) { + try { + process.kill(child.pid, 'SIGKILL') + } catch (error) { + finish(isNoSuchProcessError(error) ? undefined : error) + return + } + } + if (!settled) { + forcedTimer = setTimeout( + () => finish(new Error('Daemon did not exit after SIGKILL')), + DAEMON_CHILD_FORCE_EXIT_WAIT_MS + ) + } + }, DAEMON_CHILD_TERMINATION_GRACE_MS) + if (child.pid) { + try { + process.kill(child.pid, 'SIGTERM') + } catch (error) { + finish(isNoSuchProcessError(error) ? undefined : error) + } + } else { + finish() + } + }) + } finally { + if (child.connected) { + child.disconnect() + } + child.unref() + } +} + +function isNoSuchProcessError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' +} + async function shouldPreserveDaemonWithLiveSessions( socketPath: string, tokenPath: string, @@ -223,275 +356,351 @@ async function shouldPreserveDaemonWithLiveSessions( } function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { - return async (socketPath, tokenPath) => { + return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => { const entryPath = getDaemonEntryPath() - const health = await checkDaemonHealth(socketPath, tokenPath) - if (health === 'healthy') { - const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) - if (resolverHealth === 'unhealthy') { - const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - if (liveSessionCount !== 0) { - console.warn( - liveSessionCount === null - ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' - : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return createPreservedDaemonHandle(runtimeDir) - } - console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) - } else { - // Why: a protocol-healthy daemon can outlive the app bundle that - // launched it. In dev this happens after deleting/rebuilding a - // worktree; in packaged apps it happens when the stable - // /Applications/Orca.app path is replaced during update. - const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) - const stalePackagedBundle = - app.isPackaged && - (await isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion())) - if (identity === 'mismatch' || stalePackagedBundle) { - // Why: replacing a healthy daemon kills its child PTYs; defer code - // freshness until no live terminal sessions would be lost. - const replacementLabel = stalePackagedBundle - ? 'launched before the current app bundle was installed' - : 'launched from a different app path' - if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) { - return createPreservedDaemonHandle(runtimeDir) + const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir) + const launchNonce = suppliedLaunchNonce ?? randomUUID() + let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath }) + try { + // Why: acquire the full pair before any control-only probes so an expired + // inherited deadline cannot fire in the probe-to-adoption gap. + await adoptionClient.ensureConnected() + } catch { + adoptionClient.disconnect() + adoptionClient = null + } + const preserveDaemon = async ( + mode?: 'degraded-new-pty-fallback' + ): Promise => { + const connectedClient = adoptionClient ?? undefined + adoptionClient = null + return holdDaemonAdoptionLease( + createPreservedDaemonHandle(runtimeDir, PROTOCOL_VERSION, mode), + socketPath, + tokenPath, + connectedClient + ) + } + try { + const health = await checkDaemonHealth(socketPath, tokenPath) + if (health === 'healthy') { + const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) + if (resolverHealth === 'unhealthy') { + const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + if (liveSessionCount !== 0) { + console.warn( + liveSessionCount === null + ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' + : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return preserveDaemon() } - console.warn( - stalePackagedBundle - ? '[daemon] Replacing daemon launched before the current app bundle was installed' - : '[daemon] Replacing daemon launched from a different app path' - ) + console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { - // Why: daemon is already running from a previous app session and - // responded to a protocol-level ping. Safe to reuse. - return createPreservedDaemonHandle(runtimeDir) - } - } - } else { - // Why: a busy machine (e.g. right after an update) can time out the - // health check while the daemon is alive and owning terminals. Killing - // it would destroy every live session, so re-verify with a session list - // first. - let liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - // Why: on a Windows update relaunch the daemon can be transiently wedged - // past every RPC budget (final checkpoint flush + installer/AV disk - // pressure) while its sessions are still alive — replacing it here is what - // killed those sessions. A pipe that still accepts connections proves a - // live daemon, so give a wedged-but-connectable daemon a bounded grace to - // drain and answer before deciding. A PERMANENTLY wedged daemon (accepts - // connections but its event loop never answers hello — #8689) exhausts the - // grace and falls through to replacement below, instead of being preserved - // forever, which strands the app with zero working terminals. 'rejected' - // means the daemon answered and refused the handshake — it can never be - // adopted, so it skips the grace and replacement stays the only recovery. - let graceRetry = 0 - while ( - liveSessionCount === null && - health !== 'rejected' && - graceRetry < WEDGED_DAEMON_GRACE_RETRIES && - (await probeSocket(socketPath)) - ) { - liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - graceRetry++ - } - if (liveSessionCount !== null && liveSessionCount > 0) { - if (health === 'pty-spawn-unhealthy') { - console.warn( - `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).` - ) - return createPreservedDaemonHandle( + // Why: a protocol-healthy daemon can outlive the app bundle that + // launched it. In dev this happens after deleting/rebuilding a + // worktree; in packaged apps it happens when the stable + // /Applications/Orca.app path is replaced during update. + const identity = await getDaemonLaunchIdentity( runtimeDir, - PROTOCOL_VERSION, - 'degraded-new-pty-fallback' + socketPath, + tokenPath, + entryPath ) - } - console.warn( - `[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return createPreservedDaemonHandle(runtimeDir) - } - } - - // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID - // before respawn so the new daemon does not race the stale process. - await killStaleDaemon(runtimeDir, socketPath, tokenPath) - - const userDataPath = app.getPath('userData') - // Why: on win32 packaged, fork from a copy of the Electron runtime staged - // in userData so the daemon's image + loaded modules escape the install dir - // the NSIS updater deletes and force-closes. Staged here (not at app start) - // so the one-time copy stays off the first-paint path and is skipped on - // launches that adopt a live daemon. Fail-open: null → in-dir host, below. - const relocatedHost = materializeRelocatedDaemonHost() - // Fork the relocated entry when available; otherwise the install-dir entry. - const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath - const child = fork( - forkEntryPath, - ['--socket', socketPath, '--token', tokenPath, ...daemonLogArgs()], - { - // Why: detached daemons can outlive dev worktrees. Starting from - // userData keeps process.cwd() valid after a repo/worktree is deleted. - cwd: userDataPath, - // Why: detached + unref lets the daemon outlive the Electron process. - // stdout stays 'ignore' so the child never holds the parent's stdout - // open (which would block Electron exit); stderr is 'pipe' so a - // module-load crash during startup is captured instead of discarded - // (v1.4.129-rc.1 shipped a daemon that only logged "exited with code 1" - // because stderr was thrown away). The pipe is destroyed on readiness. - detached: true, - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - // Why: run the relocated Orca.exe copy instead of the install-dir one. - // It is byte-identical, so run-as-node behavior is unchanged; only the - // image path moves out of the updater's kill zone. - ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}), - // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain - // Node.js process instead of an Electron renderer/main process. Without - // it, Electron's GPU/display initialization can interfere with native - // module operations like node-pty's posix_spawn of the spawn-helper. - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - // Why: the detached daemon is plain Node and cannot call Electron's - // app.getPath(), but shell-ready rcfiles must live outside swept tmp. - ORCA_USER_DATA_PATH: userDataPath - } - } - ) - - // Why: keep only the startup-window stderr tail so a crash cause is - // visible without unbounded memory if the daemon spews before dying. - const STARTUP_STDERR_MAX_BYTES = 8192 - let startupStderr = '' - let collectingStderr = true - const onStartupStderr = (chunk: Buffer): void => { - if (!collectingStderr) { - return - } - startupStderr += chunk.toString('utf8') - if (startupStderr.length > STARTUP_STDERR_MAX_BYTES) { - startupStderr = startupStderr.slice(-STARTUP_STDERR_MAX_BYTES) - } - } - child.stderr?.on('data', onStartupStderr) - // Why: once the daemon is up (or has failed) the parent must not keep a - // live handle on the detached daemon's stderr — a piped stream would ref - // the parent event loop and prevent Electron from exiting cleanly. - const releaseStderr = (): void => { - collectingStderr = false - child.stderr?.off('data', onStartupStderr) - child.stderr?.destroy() - } - - // Wait for the daemon to signal readiness via IPC - await new Promise((resolve, reject) => { - let timer: ReturnType | undefined - let settled = false - function cleanupStartupListeners(): void { - if (timer) { - clearTimeout(timer) - } - child.off('message', onReadyMessage) - child.off('error', onStartupError) - child.off('exit', onStartupExit) - } - function fail(error: Error): void { - if (settled) { - return - } - settled = true - cleanupStartupListeners() - // Why: stderr was previously discarded, so a startup crash surfaced only - // as "exited with code 1". Attach the captured tail to the thrown error - // (which the fallback path reports) and log it so the real cause shows. - const stderrTail = startupStderr.trim() - if (stderrTail) { - console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`) - } - releaseStderr() - if (child.pid) { - try { - process.kill(child.pid, 'SIGTERM') - } catch { - // Already dead + const stalePackagedBundle = + app.isPackaged && + (await isDaemonStaleForCurrentBundle( + runtimeDir, + socketPath, + tokenPath, + app.getVersion() + )) + if (identity === 'mismatch' || stalePackagedBundle) { + // Why: replacing a healthy daemon kills its child PTYs; defer code + // freshness until no live terminal sessions would be lost. + const replacementLabel = stalePackagedBundle + ? 'launched before the current app bundle was installed' + : 'launched from a different app path' + if ( + await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel) + ) { + return preserveDaemon() + } + console.warn( + stalePackagedBundle + ? '[daemon] Replacing daemon launched before the current app bundle was installed' + : '[daemon] Replacing daemon launched from a different app path' + ) + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + } else { + // Why: daemon is already running from a previous app session and + // responded to a protocol-level ping. Safe to reuse. + return preserveDaemon() } } - reject( - stderrTail ? new Error(`${error.message}\nDaemon stderr (tail):\n${stderrTail}`) : error - ) + } else { + // Why: a busy machine (e.g. right after an update) can time out the + // health check while the daemon is alive and owning terminals. Killing + // it would destroy every live session, so re-verify with a session list + // first. + let liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + // Why: on a Windows update relaunch the daemon can be transiently wedged + // past every RPC budget (final checkpoint flush + installer/AV disk + // pressure) while its sessions are still alive — replacing it here is what + // killed those sessions. A pipe that still accepts connections proves a + // live daemon, so give a wedged-but-connectable daemon a bounded grace to + // drain and answer before deciding. A PERMANENTLY wedged daemon (accepts + // connections but its event loop never answers hello — #8689) exhausts the + // grace and falls through to replacement below, instead of being preserved + // forever, which strands the app with zero working terminals. 'rejected' + // means the daemon answered and refused the handshake — it can never be + // adopted, so it skips the grace and replacement stays the only recovery. + let graceRetry = 0 + while ( + liveSessionCount === null && + health !== 'rejected' && + graceRetry < WEDGED_DAEMON_GRACE_RETRIES && + (await probeSocket(socketPath)) + ) { + liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + graceRetry++ + } + if (liveSessionCount !== null && liveSessionCount > 0) { + if (health === 'pty-spawn-unhealthy') { + console.warn( + `[daemon] DEGRADED MODE: preserving daemon that failed the PTY spawn health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).` + ) + return preserveDaemon('degraded-new-pty-fallback') + } + console.warn( + `[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return preserveDaemon() + } } - function onReadyMessage(msg: unknown): void { - if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') { + + // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID + // before respawn so the new daemon does not race the stale process. + adoptionClient?.disconnect() + adoptionClient = null + await killStaleDaemon(runtimeDir, socketPath, tokenPath) + + const userDataPath = app.getPath('userData') + // Why: on win32 packaged, fork from a copy of the Electron runtime staged + // in userData so the daemon's image + loaded modules escape the install dir + // the NSIS updater deletes and force-closes. Staged here (not at app start) + // so the one-time copy stays off the first-paint path and is skipped on + // launches that adopt a live daemon. Fail-open: null → in-dir host, below. + const relocatedHost = materializeRelocatedDaemonHost() + // Fork the relocated entry when available; otherwise the install-dir entry. + const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath + const child = fork( + forkEntryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + ...daemonLogArgs() + ], + { + // Why: detached daemons can outlive dev worktrees. Starting from + // userData keeps process.cwd() valid after a repo/worktree is deleted. + cwd: userDataPath, + // Why: detached + unref lets the daemon outlive the Electron process. + // stdout stays 'ignore' so the child never holds the parent's stdout + // open (which would block Electron exit); stderr is 'pipe' so a + // module-load crash during startup is captured instead of discarded + // (v1.4.129-rc.1 shipped a daemon that only logged "exited with code 1" + // because stderr was thrown away). The pipe is destroyed on readiness. + detached: true, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + // Why: run the relocated Orca.exe copy instead of the install-dir one. + // It is byte-identical, so run-as-node behavior is unchanged; only the + // image path moves out of the updater's kill zone. + ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}), + // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain + // Node.js process instead of an Electron renderer/main process. Without + // it, Electron's GPU/display initialization can interfere with native + // module operations like node-pty's posix_spawn of the spawn-helper. + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + // Why: the detached daemon is plain Node and cannot call Electron's + // app.getPath(), but shell-ready rcfiles must live outside swept tmp. + ORCA_USER_DATA_PATH: userDataPath + } + } + ) + + // Why: keep only the startup-window stderr tail so a crash cause is + // visible without unbounded memory if the daemon spews before dying. + const STARTUP_STDERR_MAX_BYTES = 8192 + let startupStderr = '' + let collectingStderr = true + const onStartupStderr = (chunk: Buffer): void => { + if (!collectingStderr) { + return + } + startupStderr += chunk.toString('utf8') + if (startupStderr.length > STARTUP_STDERR_MAX_BYTES) { + startupStderr = startupStderr.slice(-STARTUP_STDERR_MAX_BYTES) + } + } + child.stderr?.on('data', onStartupStderr) + // Why: once the daemon is up (or has failed) the parent must not keep a + // live handle on the detached daemon's stderr — a piped stream would ref + // the parent event loop and prevent Electron from exiting cleanly. + const releaseStderr = (): void => { + collectingStderr = false + child.stderr?.off('data', onStartupStderr) + child.stderr?.destroy() + } + + // Wait for the daemon to signal readiness via IPC + await new Promise((resolve, reject) => { + let timer: ReturnType | undefined + let settled = false + function cleanupStartupListeners(): void { + if (timer) { + clearTimeout(timer) + } + child.off('message', onReadyMessage) + child.off('error', onStartupError) + child.off('exit', onStartupExit) + } + async function fail(error: Error): Promise { if (settled) { return } settled = true - // Why: the daemon process is detached after readiness; leaving - // startup listeners attached retains this launch promise closure. cleanupStartupListeners() - if (child.pid) { - // Why: JSON pid file carries pid + process start time so later - // killStaleDaemon() can verify the pid still belongs to the daemon - // we forked before SIGTERMing it. Prevents pid-recycling hazard - // where the OS hands the daemon's old pid to an unrelated process. - // Why the ready-message fallback: Windows has no cheap OS query - // for start time, so the daemon self-reports it — without this the - // recycling guard was permanently inert on win32. - const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs - writeFileSync( - getDaemonPidPath(runtimeDir), - serializeDaemonPidFile({ - pid: child.pid, - startedAtMs: - getProcessStartedAtMs(child.pid) ?? - (typeof selfReported === 'number' && Number.isFinite(selfReported) - ? selfReported - : null), - entryPath, - appVersion: app.getVersion() - }), - { mode: 0o600 } - ) + // Why: stderr was previously discarded, so a startup crash surfaced only + // as "exited with code 1". Attach the captured tail to the thrown error + // (which the fallback path reports) and log it so the real cause shows. + const stderrTail = startupStderr.trim() + if (stderrTail) { + console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`) } - // Why: disconnect IPC channel, release the stderr pipe, and unref so - // Electron can exit without waiting for the daemon. The daemon keeps - // running detached. releaseStderr() - child.disconnect() - child.unref() - resolve() - } - } - - function onStartupError(err: Error): void { - fail(err) - } - - function onStartupExit(code: number | null): void { - fail(new Error(`Daemon exited during startup with code ${code}`)) - } - - timer = setTimeout(() => { - fail(new Error('Daemon startup timed out')) - }, 10000) - - child.on('message', onReadyMessage) - child.on('error', onStartupError) - child.on('exit', onStartupExit) - }) - - return { - shutdown: async () => { - if (child.pid) { + const startupError = stderrTail + ? new Error(`${error.message}\nDaemon stderr (tail):\n${stderrTail}`) + : error try { - process.kill(child.pid, 'SIGTERM') - } catch { - // Already dead + await terminateLaunchedDaemonChild(child) + } catch (cleanupError) { + reject( + new AggregateError( + [startupError, cleanupError], + 'Daemon startup and child cleanup both failed' + ) + ) + return + } + reject(startupError) + } + function onReadyMessage(msg: unknown): void { + if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') { + if (settled) { + return + } + const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs + if ( + !Number.isSafeInteger(child.pid) || + (child.pid as number) <= 0 || + typeof selfReported !== 'number' || + !Number.isFinite(selfReported) || + selfReported <= 0 + ) { + void fail(new Error('Daemon readiness identity is incomplete')) + return + } + try { + // Why: hello and the PID record must share the daemon's self time + // and nonce so cleanup can identify this exact process incarnation. + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: child.pid as number, + startedAtMs: selfReported, + entryPath, + appVersion: app.getVersion(), + launchNonce + }), + { mode: 0o600, flag: 'wx' } + ) + } catch (error) { + void fail(error instanceof Error ? error : new Error(String(error))) + return + } + settled = true + // Why: the daemon process is detached after readiness; leaving + // startup listeners attached retains this launch promise closure. + cleanupStartupListeners() + // Why: disconnect IPC channel, release the stderr pipe, and unref so + // Electron can exit without waiting for the daemon. The daemon keeps + // running detached. + releaseStderr() + child.disconnect() + child.unref() + resolve() } } + + function onStartupError(err: Error): void { + void fail(err) + } + + function onStartupExit(code: number | null): void { + void fail(new Error(`Daemon exited during startup with code ${code}`)) + } + + timer = setTimeout(() => { + void fail(new Error('Daemon startup timed out')) + }, 10000) + + child.on('message', onReadyMessage) + child.on('error', onStartupError) + child.on('exit', onStartupExit) + }) + + try { + return await holdDaemonAdoptionLease( + { + shutdown: () => terminateLaunchedDaemonChild(child) + }, + socketPath, + tokenPath + ) + } catch (error) { + // Why: another client may have adopted this still-live process. Keep its + // valid identity until exit, but remove a record published after an early exit. + let pidRecordRemoved = false + const removeExitedPidRecord = (): void => { + if (pidRecordRemoved) { + return + } + pidRecordRemoved = true + unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) + } + child.once('exit', removeExitedPidRecord) + if ( + (child.exitCode !== null && child.exitCode !== undefined) || + (child.signalCode !== null && child.signalCode !== undefined) + ) { + child.off('exit', removeExitedPidRecord) + removeExitedPidRecord() + } + throw error } + } catch (error) { + adoptionClient?.disconnect() + throw error } } } @@ -525,7 +734,14 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise logDaemonMilestone('daemon-current-ready') if (signal?.aborted) { // Why: startup fail-open may already have allowed fallback LocalPtyProvider - // PTYs to spawn. A late daemon swap would strand those PTYs on the old owner. + // PTYs to spawn. Do not install late, but give an empty daemon one bounded + // authenticated retirement attempt; live adopted sessions reject it and survive. + const abortedStartupAdapter = new DaemonPtyAdapter({ + socketPath: info.socketPath, + tokenPath: info.tokenPath + }) + releaseDaemonAdoptionLease(newSpawner.getHandle()) + await abortedStartupAdapter.disconnectOnly() return } @@ -541,37 +757,54 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise console.warn('[daemon] Daemon process died — respawning') newSpawner.resetHandle() await newSpawner.ensureRunning() + return takeDaemonAdoptionLeaseRelease(newSpawner.getHandle()) } }) + let legacyAdapters: DaemonPtyAdapter[] = [] + let routedAdapter: DaemonProvider = newAdapter + try { + // Why: the launcher's temporary pair closes only after this permanent + // adapter pair is established, leaving no retirement gap during adoption. + await newAdapter.establishLifecycleLease() + releaseDaemonAdoptionLease(newSpawner.getHandle()) - const legacyAdapters = await createLegacyDaemonAdapters(runtimeDir) - const routedAdapter = - launchMode === 'degraded-new-pty-fallback' - ? new DegradedDaemonPtyProvider({ - current: newAdapter, - legacy: legacyAdapters, - fallback: getLocalPtyProvider() - }) - : legacyAdapters.length > 0 - ? new DaemonPtyRouter({ + legacyAdapters = await createLegacyDaemonAdapters(runtimeDir) + routedAdapter = + launchMode === 'degraded-new-pty-fallback' + ? new DegradedDaemonPtyProvider({ current: newAdapter, - legacy: legacyAdapters + legacy: legacyAdapters, + fallback: getLocalPtyProvider() }) - : newAdapter - if (routedAdapter instanceof DegradedDaemonPtyProvider) { - // Why: the preserved daemon cannot create fresh terminals, but its live - // sessions may still be writable. Discover those ids so only known old - // sessions route to the degraded daemon; fresh panes fall back locally. - await routedAdapter.discoverDaemonSessions() - } else if (routedAdapter instanceof DaemonPtyRouter) { - await routedAdapter.discoverLegacySessions() + : legacyAdapters.length > 0 + ? new DaemonPtyRouter({ + current: newAdapter, + legacy: legacyAdapters + }) + : newAdapter + if (routedAdapter instanceof DegradedDaemonPtyProvider) { + // Why: the preserved daemon cannot create fresh terminals, but its live + // sessions may still be writable. Discover those ids so only known old + // sessions route to the degraded daemon; fresh panes fall back locally. + await routedAdapter.discoverDaemonSessions() + } else if (routedAdapter instanceof DaemonPtyRouter) { + await routedAdapter.discoverLegacySessions() + } + if (signal?.aborted) { + // Why: same late-swap guard after legacy discovery, which can also exceed + // the first-window startup timeout on slow or stale daemon state. Release + // every uninstalled adapter lease without killing its live sessions. + await routedAdapter.disconnectOnly() + return + } + } catch (error) { + try { + await cleanupFailedDaemonAdoption(newSpawner, newAdapter, legacyAdapters) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Daemon adoption and cleanup both failed') + } + throw error } - if (signal?.aborted) { - // Why: same late-swap guard after legacy discovery, which can also exceed - // the first-window startup timeout on slow or stale daemon state. - return - } - spawner = newSpawner adapter = routedAdapter setLocalPtyProvider(routedAdapter) @@ -713,12 +946,20 @@ async function runRestartDaemon(): Promise { // Step 3: kill the current-protocol daemon process (shutdown RPC → fallback // killStaleDaemon → socket/pid unlink). Legacy adapters untouched. - await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + let info: Awaited> + try { + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) - // Step 4: reuse the existing spawner so the respawn closure baked into - // long-lived adapters stays valid. Do NOT construct a new DaemonSpawner. - currentSpawner.resetHandle() - const info = await currentSpawner.ensureRunning() + // Step 4: reuse the existing spawner so the respawn closure baked into + // long-lived adapters stays valid. Do NOT construct a new DaemonSpawner. + currentSpawner.resetHandle() + info = await currentSpawner.ensureRunning() + } catch (error) { + // Why: the old provider remains authoritative until the final swap, even + // if cleanup or replacement launch fails after listener teardown. + rebindLocalProviderListeners() + throw error + } // Step 5: build a fresh current adapter against the respawned daemon. Its // respawn callback closes over the same spawner instance (identical to the @@ -731,18 +972,43 @@ async function runRestartDaemon(): Promise { console.warn('[daemon] Daemon process died — respawning') currentSpawner.resetHandle() await currentSpawner.ensureRunning() + return takeDaemonAdoptionLeaseRelease(currentSpawner.getHandle()) } }) + let newProvider: DaemonProvider = newCurrent + try { + // Why: the temporary launcher lease overlaps this permanent pair so manual + // restart cannot strand a newly spawned daemon during adoption. + await newCurrent.establishLifecycleLease() + releaseDaemonAdoptionLease(currentSpawner.getHandle()) - // Re-wrap in router if there were legacy adapters at startup; otherwise - // point straight at the new adapter. Legacy instances are preserved by - // reference — they still route to the same pre-upgrade daemons. - const newProvider = - legacyAdapters.length > 0 - ? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters }) - : newCurrent - if (newProvider instanceof DaemonPtyRouter) { - await newProvider.discoverLegacySessions() + // Re-wrap in router if there were legacy adapters at startup; otherwise + // point straight at the new adapter. Legacy instances are preserved by + // reference — they still route to the same pre-upgrade daemons. + newProvider = + legacyAdapters.length > 0 + ? new DaemonPtyRouter({ current: newCurrent, legacy: legacyAdapters }) + : newCurrent + if (newProvider instanceof DaemonPtyRouter) { + await newProvider.discoverLegacySessions() + } + } catch (error) { + let cleanupError: unknown + try { + if (newProvider instanceof DaemonPtyRouter) { + newProvider.disposeRouterOnly() + } + await cleanupFailedDaemonAdoption(currentSpawner, newCurrent) + } catch (caught) { + cleanupError = caught + } + // Why: the previous provider remains module-authoritative until the swap; + // restore its renderer bindings when replacement adoption fails. + rebindLocalProviderListeners() + if (cleanupError) { + throw new AggregateError([error, cleanupError], 'Daemon restart and cleanup both failed') + } + throw error } // Why: drain the outgoing router's subscriptions from the shared legacy @@ -777,11 +1043,6 @@ export async function shutdownDaemon(): Promise { adapter = null await spawner?.shutdown() spawner = null - try { - unlinkSync(getDaemonPidPath(getRuntimeDir())) - } catch { - // Best-effort - } } export type OrphanedDaemonCleanupResult = { @@ -803,6 +1064,11 @@ export async function cleanupDaemonForProtocol( const alive = await probeSocket(socketPath) if (!alive) { + if (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + // Why: endpoint absence does not prove a canonical PID record still + // belongs to the current protocol; the exact daemon owns its artifact cleanup. + return { cleaned: false, killedCount: 0 } + } // Why: still best-effort remove a stale socket file so a future opt-in // launch doesn't hit EADDRINUSE when the daemon tries to bind. if (process.platform !== 'win32' && existsSync(socketPath)) { @@ -848,6 +1114,15 @@ export async function cleanupDaemonForProtocol( client.disconnect() } + if (didRequestShutdown && protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + if (!(await waitForDaemonEndpointExit(socketPath))) { + // Why: never fork a replacement while the old incarnation may still + // own the endpoint or be disposing terminal children. + throw new Error('Timed out waiting for daemon self-shutdown') + } + return { cleaned: true, killedCount } + } + // Why: after `shutdown`, the daemon unlinks its socket itself — but on some // crash paths the file lingers. Clean up defensively so a later opt-in // relaunch can bind cleanly. @@ -867,6 +1142,17 @@ export async function cleanupDaemonForProtocol( return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount } } +async function waitForDaemonEndpointExit(socketPath: string): Promise { + const deadline = Date.now() + DAEMON_SELF_SHUTDOWN_WAIT_MS + while (Date.now() < deadline) { + if (!(await probeSocket(socketPath))) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return !(await probeSocket(socketPath)) +} + function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean { try { const parsed = parseDaemonPidFile( diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 8d2d0a36c64..9884cdf0c80 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -4,9 +4,16 @@ import type { DaemonFileLog } from './daemon-file-log' export type DaemonStartOptions = { socketPath: string tokenPath: string + pidPath?: string + launchNonce?: string + startedAtMs?: number + /** Direct-construction seam for versioned protocol fixtures; never CLI/env configured. */ + protocolVersion?: number spawnSubprocess: DaemonServerOptions['spawnSubprocess'] preparePtySpawn?: DaemonServerOptions['preparePtySpawn'] log?: DaemonFileLog + onIdleShutdown?: () => void + initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] } export type DaemonHandle = { @@ -17,9 +24,17 @@ export async function startDaemon(opts: DaemonStartOptions): Promise Promise + respawn?: () => Promise void)> } const MAX_TOMBSTONES = 1000 @@ -92,7 +93,9 @@ export class DaemonPtyAdapter implements IPtyProvider { private client: DaemonClient private historyManager: HistoryManager | null private historyReader: HistoryReader | null - private respawnFn: (() => Promise) | null + private respawnFn: (() => Promise void)>) | null + private pendingRespawnAdoptionRelease: (() => void) | null = null + private respawnAdoptionClosed = false // Why: multiple pane mounts can call spawn() concurrently. If the daemon is // dead, all calls enter withDaemonRetry's catch block at once. Without a // lock, each would fork its own daemon process. This promise coalesces @@ -958,6 +961,8 @@ export class DaemonPtyAdapter implements IPtyProvider { } dispose(): void { + this.respawnAdoptionClosed = true + this.releasePendingRespawnAdoptionLease() this.stopCheckpointTimer() this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() @@ -978,6 +983,15 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.disconnect() } + async establishLifecycleLease(): Promise { + if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { + return + } + // Why: an authenticated pair cancels the launch-adoption watchdog and gives + // a never-used adapter authority to retire its empty daemon during clean quit. + await this.client.ensureConnected() + } + // Why: for in-process daemon mode, disconnect without flushing history. // dispose() writes endedAt for all sessions, which would prevent cold // restore. disconnectOnly() leaves history files in unclean state so @@ -985,6 +999,8 @@ export class DaemonPtyAdapter implements IPtyProvider { // We write a final checkpoint before disconnecting so that if the daemon // later crashes while Orca is closed, checkpoint.json has recovery data. async disconnectOnly(): Promise { + this.respawnAdoptionClosed = true + this.releasePendingRespawnAdoptionLease() this.stopCheckpointTimer() // Why: wait for any in-flight timer pass to finish before starting // the final checkpoint. Otherwise both passes race on the shared tmp @@ -1011,11 +1027,31 @@ export class DaemonPtyAdapter implements IPtyProvider { this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() this.removeEventListener = null + if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + try { + // Why: only the authenticated daemon can atomically prove it is empty; + // one shared budget keeps a first connection plus retirement off quit's critical path. + const deadlineMs = Date.now() + 250 + if (!this.client.isConnected()) { + await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) + } + await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now())) + } catch { + // An unreachable daemon falls back to event-driven retirement when its + // authenticated sockets close and it can prove itself empty. + } + } this.client.disconnect() } private async ensureConnected(): Promise { - await this.client.ensureConnected() + try { + await this.client.ensureConnected() + } finally { + // Why: a respawn launcher holds a temporary full pair until this adapter + // has attempted its permanent reconnect, preventing both gaps and leaks. + this.releasePendingRespawnAdoptionLease() + } // Why sampled before setupEventRouting: routing is (re)installed exactly // once per connection, so "no listener yet" identifies a fresh connect — // the only time the daemon-side backgrounded set needs a resync (it is @@ -1298,7 +1334,15 @@ export class DaemonPtyAdapter implements IPtyProvider { try { return await fn() } catch (err) { - if (!this.respawnFn || !isDaemonGoneError(err)) { + // Why: self-retirement removes the token only after an authenticated + // endpoint dropped; an initial missing token may still hide a live daemon. + const missingRetiredEndpointToken = + isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect() + if ( + this.respawnAdoptionClosed || + !this.respawnFn || + (!isDaemonGoneError(err) && !missingRetiredEndpointToken) + ) { throw err } if (!this.respawnPromise) { @@ -1307,7 +1351,13 @@ export class DaemonPtyAdapter implements IPtyProvider { }) } await this.respawnPromise - return await fn() + try { + return await fn() + } finally { + // Why: the retried operation may reject before it reaches a connection + // attempt (for example, a tombstone racing respawn). + this.releasePendingRespawnAdoptionLease() + } } } @@ -1371,7 +1421,20 @@ export class DaemonPtyAdapter implements IPtyProvider { this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() - await this.respawnFn!() + const releaseAdoptionLease = await this.respawnFn!() + if (this.respawnAdoptionClosed) { + // Why: app teardown may win while the launcher is still acquiring its + // temporary pair; a late result must not reinstall a lease nobody owns. + releaseAdoptionLease?.() + throw new Error('Daemon adapter closed during respawn') + } + this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null + } + + private releasePendingRespawnAdoptionLease(): void { + const release = this.pendingRespawnAdoptionRelease + this.pendingRespawnAdoptionRelease = null + release?.() } private setupEventRouting(): void { @@ -1476,5 +1539,18 @@ function isDaemonGoneError(err: unknown): boolean { return true } const msg = err.message - return msg === 'Connection lost' || msg === 'Not connected' || msg === 'Hello response timed out' + return ( + msg === 'Connection lost' || + msg === 'Not connected' || + msg === 'Hello response timed out' || + msg === 'Daemon temporarily unavailable; reconnect' + ) +} + +function isMissingTokenFileError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + return errno.code === 'ENOENT' && errno.syscall === 'open' } diff --git a/src/main/daemon/daemon-self-retirement-respawn.test.ts b/src/main/daemon/daemon-self-retirement-respawn.test.ts new file mode 100644 index 00000000000..a7184272111 --- /dev/null +++ b/src/main/daemon/daemon-self-retirement-respawn.test.ts @@ -0,0 +1,228 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import type { SubprocessHandle } from './session' + +function fixtureSubprocess(): SubprocessHandle { + let onExit: ((code: number) => void) | null = null + return { + pid: process.pid, + getForegroundProcess: () => null, + write: () => {}, + resize: () => {}, + kill: () => queueMicrotask(() => onExit?.(0)), + forceKill: () => queueMicrotask(() => onExit?.(137)), + signal: () => {}, + onData: () => {}, + onExit: (callback) => { + onExit = callback + }, + dispose: () => {} + } +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000 + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for daemon disconnect') + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +describe('daemon self-retirement respawn', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer | null + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-retirement-respawn-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + server = null + }) + + afterEach(async () => { + await server?.shutdown().catch(() => {}) + rmSync(dir, { recursive: true, force: true }) + }) + + async function startServer(): Promise { + const next = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => fixtureSubprocess() + }) + await next.start() + server = next + return next + } + + it('coalesces respawn after an authenticated endpoint removes its token', async () => { + const original = await startServer() + const respawn = vi.fn(async () => { + await startServer() + }) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean } + } + ).client + + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + + await Promise.all([ + adapter.spawn({ sessionId: 'first', cols: 80, rows: 24 }), + adapter.spawn({ sessionId: 'second', cols: 80, rows: 24 }) + ]) + + expect(respawn).toHaveBeenCalledTimes(1) + adapter.dispose() + }) + + it('releases the temporary respawn lease before clean retirement', async () => { + const original = await startServer() + let temporaryAdapter: DaemonPtyAdapter | null = null + const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose()) + const respawn = vi.fn(async () => { + await startServer() + temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + await temporaryAdapter.establishLifecycleLease() + return releaseTemporaryLease + }) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean } + } + ).client + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + + await adapter.spawn({ sessionId: 'respawned', cols: 80, rows: 24 }) + expect(releaseTemporaryLease).toHaveBeenCalledOnce() + await adapter.shutdown('respawned', { immediate: true }) + await adapter.disconnectOnly() + + await waitFor(() => !existsSync(tokenPath)) + }) + + it('releases a temporary lease when a tombstone wins the respawn race', async () => { + const original = await startServer() + let temporaryAdapter: DaemonPtyAdapter | null = null + const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose()) + let adapter!: DaemonPtyAdapter + const respawn = vi.fn(async () => { + await startServer() + temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + await temporaryAdapter.establishLifecycleLease() + const tombstones = (adapter as unknown as { killedSessionTombstones: Map }) + .killedSessionTombstones + tombstones.set('closed-during-respawn', Date.now()) + return releaseTemporaryLease + }) + adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean } + } + ).client + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + + await expect( + adapter.spawn({ sessionId: 'closed-during-respawn', cols: 80, rows: 24 }) + ).rejects.toThrow('was explicitly killed') + + expect(releaseTemporaryLease).toHaveBeenCalledOnce() + adapter.dispose() + }) + + it('releases a lease returned after disposal and does not reconnect', async () => { + const original = await startServer() + let temporaryAdapter: DaemonPtyAdapter | null = null + const releaseTemporaryLease = vi.fn(() => temporaryAdapter?.dispose()) + let returnRespawnLease!: () => void + const respawn = vi.fn(async () => { + await startServer() + temporaryAdapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + await temporaryAdapter.establishLifecycleLease() + await new Promise((resolve) => { + returnRespawnLease = resolve + }) + return releaseTemporaryLease + }) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean; isConnected(): boolean } + } + ).client + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + + const spawn = adapter.spawn({ sessionId: 'disposed', cols: 80, rows: 24 }) + await waitFor(() => returnRespawnLease !== undefined) + adapter.dispose() + returnRespawnLease() + + await expect(spawn).rejects.toThrow('closed during respawn') + expect(releaseTemporaryLease).toHaveBeenCalledOnce() + expect(client.isConnected()).toBe(false) + }) + + it('does not start a respawn after disposal rejects an in-flight operation', async () => { + await startServer() + const respawn = vi.fn(async () => {}) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.establishLifecycleLease() + let rejectCreate!: (error: Error) => void + const client = ( + adapter as unknown as { + client: { + request: (method: string, params: unknown) => Promise + } + } + ).client + vi.spyOn(client, 'request').mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectCreate = reject + }) + ) + + const spawn = adapter.spawn({ sessionId: 'disposed-in-flight', cols: 80, rows: 24 }) + await waitFor(() => rejectCreate !== undefined) + adapter.dispose() + rejectCreate(new Error('Connection lost')) + + await expect(spawn).rejects.toThrow('Connection lost') + expect(respawn).not.toHaveBeenCalled() + }) + + it('does not treat an initial missing token as respawn authority', async () => { + const respawn = vi.fn(async () => {}) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + + await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toMatchObject( + { + code: 'ENOENT' + } + ) + + expect(respawn).not.toHaveBeenCalled() + adapter.dispose() + }) +}) diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 40a624103cc..aedf2384e08 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -2,13 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect, type Server, type Socket } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { existsSync, mkdtempSync, rmSync, readFileSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonServer } from './daemon-server' import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' import { PROTOCOL_VERSION, type DaemonRequest } from './types' import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' +import { getDaemonPidPath, getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner' const confirmForegroundProcessMock = vi.fn(async () => 'droid') @@ -58,6 +58,7 @@ type DaemonServerPrivate = { clientId: string controlSocket: Socket streamSocket: Socket | null + authenticatedPairEstablished: boolean } > routeRequest(clientId: string, request: DaemonRequest): Promise @@ -67,6 +68,7 @@ describe('DaemonServer', () => { let dir: string let socketPath: string let tokenPath: string + let pidPath: string let server: DaemonServer let client: DaemonClient @@ -75,6 +77,7 @@ describe('DaemonServer', () => { dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') + pidPath = getDaemonPidPath(dir) }) afterEach(async () => { @@ -83,10 +86,11 @@ describe('DaemonServer', () => { rmSync(dir, { recursive: true, force: true }) }) - async function startServer(): Promise { + async function startServer(launchNonce?: string): Promise { server = new DaemonServer({ socketPath, tokenPath, + ...(launchNonce ? { pidPath, launchNonce } : {}), spawnSubprocess: () => createMockSubprocess() }) await server.start() @@ -290,12 +294,12 @@ describe('DaemonServer', () => { spawnSubprocess }) await server.start() - const daemon = server as unknown as DaemonServerPrivate + const c = await connectClient() - const create = daemon.routeRequest('shutdown-client', { - id: 'shutdown-create', - type: 'createOrAttach', - payload: { sessionId: 'shutdown-pending', cols: 80, rows: 24 } + const create = c.request('createOrAttach', { + sessionId: 'shutdown-pending', + cols: 80, + rows: 24 }) const canceledCreate = expect(create).rejects.toThrow( 'Attach canceled for session shutdown-pending' @@ -535,7 +539,8 @@ describe('DaemonServer', () => { daemon.clients.set('client-1', { clientId: 'client-1', controlSocket, - streamSocket + streamSocket, + authenticatedPairEstablished: true }) await daemon.routeRequest('client-1', { @@ -591,7 +596,8 @@ describe('DaemonServer', () => { daemon.clients.set('client-1', { clientId: 'client-1', controlSocket, - streamSocket + streamSocket, + authenticatedPairEstablished: true }) await daemon.routeRequest('client-1', { @@ -648,7 +654,8 @@ describe('DaemonServer', () => { daemon.clients.set('client-1', { clientId: 'client-1', controlSocket, - streamSocket + streamSocket, + authenticatedPairEstablished: true }) await daemon.routeRequest('client-1', { id: 'req-1', @@ -780,6 +787,68 @@ describe('DaemonServer', () => { }) describe('shutdown', () => { + it('waits for the ordinary shutdown reply write before destroying resources', async () => { + await startServer() + const c = await connectClient() + const daemon = server as unknown as DaemonServerPrivate & { + host: { dispose: () => Promise } + } + const controlSocket = [...daemon.clients.values()][0].controlSocket + const originalWrite = controlSocket.write.bind(controlSocket) + let replyFlushed: (() => void) | undefined + vi.spyOn(controlSocket, 'write').mockImplementation((( + chunk: string | Uint8Array, + ...args: unknown[] + ) => { + replyFlushed = args.find((arg) => typeof arg === 'function') as (() => void) | undefined + return originalWrite(chunk) + }) as unknown as Socket['write']) + const dispose = vi.spyOn(daemon.host, 'dispose') + + await expect(c.request('shutdown', { killSessions: false })).resolves.toEqual({}) + expect(dispose).not.toHaveBeenCalled() + expect(existsSync(tokenPath)).toBe(true) + + replyFlushed?.() + await waitFor(() => !existsSync(tokenPath)) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('removes only its owned token and PID record', async () => { + const launchNonce = 'ordinary-shutdown' + writeFileSync( + pidPath, + serializeDaemonPidFile({ pid: process.pid, startedAtMs: null, launchNonce }) + ) + await startServer(launchNonce) + + await server.shutdown() + + expect(existsSync(tokenPath)).toBe(false) + expect(existsSync(pidPath)).toBe(false) + }) + + it('preserves token and PID artifacts replaced before ordinary cleanup', async () => { + await startServer('mine') + writeFileSync(tokenPath, 'replacement-token') + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: process.pid, + startedAtMs: null, + launchNonce: 'replacement' + }) + ) + + await server.shutdown() + + expect(readFileSync(tokenPath, 'utf8')).toBe('replacement-token') + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({ + pid: process.pid, + launchNonce: 'replacement' + }) + }) + it('stops accepting connections after shutdown', async () => { await startServer() await server.shutdown() @@ -800,9 +869,7 @@ describe('DaemonServer', () => { ) const c = await connectClient() - // The daemon may self-terminate before the reply flushes; callers treat - // that as success, so only the observable teardown below is asserted. - await c.request('shutdown', { killSessions: true }).catch(() => {}) + await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({}) await waitFor(() => daemon.server === null) await waitFor(() => !existsSync(socketPath)) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index c5d6c04dfee..15ec9fecd21 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -4,7 +4,7 @@ import { createServer, type Server, type Socket } from 'node:net' import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' -import { writeFileSync, chmodSync, unlinkSync } from 'node:fs' +import { writeFileSync, chmodSync } from 'node:fs' import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' @@ -23,7 +23,9 @@ import type { SubprocessHandle } from './session' import { checkPtySpawnHealth } from './pty-subprocess' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' import { isTuiAgent } from '../../shared/tui-agent-config' +import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' import { + CLEAN_DISCONNECT_PROTOCOL_VERSION, PROTOCOL_VERSION, NOTIFY_PREFIX, SessionNotFoundError, @@ -35,6 +37,21 @@ import { export type DaemonServerOptions = { socketPath: string tokenPath: string + pidPath?: string + launchNonce?: string + startedAtMs?: number + /** Direct-construction seam for protocol fixture tests; production never overrides it. */ + protocolVersion?: number + onIdleShutdown?: () => void + /** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */ + initialAdoptionTestConfig?: { + timeoutMs: number + clock: { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number + } + } ptySpawnHealthCheck?: () => Promise preparePtySpawn?: () => Promise log?: DaemonFileLog @@ -53,21 +70,46 @@ type ConnectedClient = { clientId: string controlSocket: Socket streamSocket: Socket | null + authenticatedPairEstablished: boolean } type PendingPtySpawnPreparation = { canceled: boolean } +type PendingShutdownReply = { + start: () => void +} + export class DaemonServer { + // Why: a new daemon must survive long enough for its first client pair, but + // a parent crash between launch and adoption must not orphan it forever. + private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 + private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 private server: Server | null = null private token: string private host: TerminalHost private socketPath: string private tokenPath: string + private pidPath: string | null + private launchNonce: string | null + private startedAtMs: number | null + private protocolVersion: number + private onIdleShutdown: () => void private ptySpawnHealthCheck: () => Promise private preparePtySpawn: () => Promise private log: DaemonFileLog + private transportSockets = new Set() + private createOrAttachInFlight = 0 + private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' + private initialAdoptionTimer: unknown | null = null + private initialAdoptionDeadlineMs: number | null = null + private retirementRequested = false + private shutdownPromise: Promise | null = null + private ordinaryShutdownServerClose: Promise | null = null + private pendingShutdownReplies = new Map() + private initialAdoptionTimeoutMs: number + private lifecycleClock: NonNullable['clock'] private clients = new Map() private streamDataBatcher = new DaemonStreamDataBatcher( @@ -116,6 +158,28 @@ export class DaemonServer { constructor(opts: DaemonServerOptions) { this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath + this.pidPath = opts.pidPath ?? null + this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.launchNonce = + opts.launchNonce ?? + (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null) + this.startedAtMs = + opts.startedAtMs ?? + (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION + ? Date.now() - process.uptime() * 1000 + : null) + this.onIdleShutdown = opts.onIdleShutdown ?? (() => {}) + this.initialAdoptionTimeoutMs = + opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS + this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? { + setTimeout: (callback, delayMs) => { + const timer = setTimeout(callback, delayMs) + timer.unref() + return timer + }, + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + now: () => Date.now() + } this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth @@ -150,12 +214,48 @@ export class DaemonServer { } catch { // Best-effort on platforms that support it } + if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + // Why: a parent crash before the first full client pair must not leave + // a freshly published, empty daemon alive forever. + this.armInitialAdoptionTimeout() + } resolve() }) }) } async shutdown(): Promise { + if (this.shutdownPromise) { + return this.shutdownPromise + } + const serverClose = this.beginOrdinaryShutdownFence() + this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + return this.shutdownPromise + } + + private beginOrdinaryShutdownFence(): Promise { + this.idleShutdownState = 'shutting-down' + this.cancelInitialAdoptionTimer() + this.ordinaryShutdownServerClose ??= this.beginServerClose() + return this.ordinaryShutdownServerClose + } + + private async finishOrdinaryShutdown(serverClose: Promise): Promise { + this.unlinkOwnedEndpointArtifacts() + await this.disposeDaemonResources() + await serverClose + } + + private unlinkOwnedEndpointArtifacts(): void { + // Why: close has already fenced this endpoint, but ownership checks still + // prevent a late replacement's canonical token or PID record from removal. + unlinkOwnedDaemonTokenFile(this.tokenPath, this.token) + if (this.pidPath && this.launchNonce) { + unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) + } + } + + private async disposeDaemonResources(): Promise { this.stopStreamBacklogProbe() this.transientFactRelay.dispose() this.cancelAllPendingPtySpawnPreparations() @@ -169,29 +269,142 @@ export class DaemonServer { }) } this.streamDataBatcher.clear() + this.pendingShutdownReplies.clear() for (const [, client] of this.clients) { client.controlSocket.destroy() client.streamSocket?.destroy() } this.clients.clear() + for (const socket of this.transportSockets) { + socket.destroy() + } + this.transportSockets.clear() + } + private beginServerClose(): Promise { + const server = this.server + this.server = null + if (!server) { + return Promise.resolve() + } return new Promise((resolve) => { - if (this.server) { - this.server.close(() => { - try { - unlinkSync(this.socketPath) - } catch {} - resolve() - }) - this.server = null - } else { + // Why: call close synchronously before any awaited cleanup so no new + // transport can enter after the idle fence is proven empty. + server.close(() => { + // Node owns unlinking its Unix listener. An extra check-then-unlink here could + // delete a replacement endpoint installed concurrently after close. resolve() - } + }) }) } + private isIdle(): boolean { + return ( + this.transportSockets.size === 0 && + this.clients.size === 0 && + this.createOrAttachInFlight === 0 && + this.host.listSessions().length === 0 + ) + } + + private reevaluateIdleShutdown(): void { + if (this.idleShutdownState !== 'running') { + return + } + if (this.retirementRequested) { + this.cancelInitialAdoptionTimer() + if (this.isIdle()) { + this.beginIdleShutdown() + } + return + } + if (!this.isIdle() || this.initialAdoptionDeadlineMs === null) { + this.cancelInitialAdoptionTimer() + return + } + if (this.initialAdoptionTimer !== null) { + return + } + const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.lifecycleClock.now()) + if (remainingMs === 0) { + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + return + } + this.initialAdoptionTimer = this.lifecycleClock.setTimeout(() => { + this.initialAdoptionTimer = null + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + }, remainingMs) + } + + private armInitialAdoptionTimeout(): void { + this.initialAdoptionDeadlineMs = this.lifecycleClock.now() + this.initialAdoptionTimeoutMs + this.reevaluateIdleShutdown() + } + + private cancelInitialAdoptionTimer(): void { + if (this.initialAdoptionTimer === null) { + return + } + this.lifecycleClock.clearTimeout(this.initialAdoptionTimer) + this.initialAdoptionTimer = null + } + + private beginIdleShutdown(): void { + this.initialAdoptionTimer = null + if (this.idleShutdownState !== 'running') { + return + } + this.idleShutdownState = 'idle-shutdown-pending' + if (!this.isIdle()) { + // Why: work admitted before the fence wins. Clearing the pending state + // keeps that already-started client/session fully usable. + this.idleShutdownState = 'running' + this.reevaluateIdleShutdown() + return + } + + this.idleShutdownState = 'shutting-down' + // beginServerClose() runs synchronously up to server.close(), before host + // disposal or file cleanup can yield to a racing connection. + const serverClose = this.beginServerClose() + this.shutdownPromise = this.finishIdleShutdown(serverClose) + } + + private async finishIdleShutdown(serverClose: Promise): Promise { + this.unlinkOwnedEndpointArtifacts() + await this.disposeDaemonResources() + await serverClose + this.onIdleShutdown() + } + private handleConnection(socket: Socket): void { + this.cancelInitialAdoptionTimer() + this.transportSockets.add(socket) + const removeTransport = (): void => { + this.transportSockets.delete(socket) + this.reevaluateIdleShutdown() + } + socket.once('close', removeTransport) + socket.on('error', () => socket.destroy()) + + if (this.idleShutdownState !== 'running') { + // Why: an accepted connection queued just before server.close() must get + // an explicit retry signal instead of appearing authenticated then dying. + socket.end( + encodeNdjson({ + type: 'hello', + ok: false, + error: 'Daemon temporarily unavailable; reconnect', + retryable: true + }) + ) + return + } // Why: clients can send multibyte prompt/input text split across socket // chunks; keep UTF-8 sequences intact before NDJSON parsing. const decoder = new StringDecoder('utf8') @@ -203,7 +416,6 @@ export class DaemonServer { ) socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) - socket.on('error', () => socket.destroy()) } private handleFirstMessage( @@ -219,7 +431,7 @@ export class DaemonServer { return } - if (hello.version !== PROTOCOL_VERSION) { + if (hello.version !== this.protocolVersion) { this.log.log('client-hello-rejected', { reason: 'protocol-mismatch', clientVersion: hello.version @@ -237,18 +449,34 @@ export class DaemonServer { } this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) - socket.write(encodeNdjson({ type: 'hello', ok: true })) + socket.write( + encodeNdjson({ + type: 'hello', + ok: true, + ...(this.launchNonce && this.startedAtMs + ? { + daemonIdentity: { + pid: process.pid, + startedAtMs: this.startedAtMs, + launchNonce: this.launchNonce + } + } + : {}) + }) + ) if (hello.role === 'control') { const previous = this.clients.get(hello.clientId) const client: ConnectedClient = { clientId: hello.clientId, controlSocket: socket, - streamSocket: null + streamSocket: null, + authenticatedPairEstablished: false } this.clients.set(hello.clientId, client) this.setupControlSocket(socket, hello.clientId) if (previous) { + this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) // Why: a reconnect can reuse a clientId before the old sockets notice // their close. Tear them down after installing the new owner so stale // close events cannot delete the replacement client entry. @@ -264,6 +492,12 @@ export class DaemonServer { return } this.setupStreamSocket(socket, client) + client.authenticatedPairEstablished = true + // A complete app connection, unlike a health or raw socket probe, owns + // the endpoint again and cancels pending event-driven retirement. + this.initialAdoptionDeadlineMs = null + this.retirementRequested = false + this.cancelInitialAdoptionTimer() } } @@ -285,12 +519,28 @@ export class DaemonServer { if (client?.controlSocket !== socket) { return } + const wasFullyAuthenticated = client.authenticatedPairEstablished this.streamDataBatcher.clear(clientId) client.streamSocket?.destroy() this.clients.delete(clientId) + this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated) + this.reevaluateIdleShutdown() }) } + private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void { + if ( + !wasFullyAuthenticated || + [...this.clients.values()].some((remaining) => remaining.authenticatedPairEstablished) || + this.idleShutdownState !== 'running' + ) { + return + } + // Why: once the last full client is gone, exact daemon-side emptiness is + // sufficient; incomplete transports may block but never erase this request. + this.retirementRequested = true + } + private setupStreamSocket(socket: Socket, client: ConnectedClient): void { const previous = client.streamSocket socket.removeAllListeners('data') @@ -330,7 +580,12 @@ export class DaemonServer { try { const result = await this.routeRequest(clientId, request) if (!isNotify) { - socket.write(encodeNdjson({ id: request.id, ok: true, payload: result })) + const pendingShutdown = this.pendingShutdownReplies.get( + this.shutdownReplyKey(clientId, request.id) + ) + socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => { + pendingShutdown?.start() + }) } } catch (err) { if (!isNotify) { @@ -345,6 +600,41 @@ export class DaemonServer { } } + private shutdownReplyKey(clientId: string, requestId: string): string { + return `${clientId}\u0000${requestId}` + } + + private deferShutdownUntilReply( + clientId: string, + requestId: string, + socket: Socket, + finish: () => Promise + ): void { + const key = this.shutdownReplyKey(clientId, requestId) + let started = false + let timer: ReturnType + const start = (): void => { + if (started) { + return + } + started = true + clearTimeout(timer) + socket.off('close', start) + socket.off('error', start) + this.pendingShutdownReplies.delete(key) + if (!this.shutdownPromise) { + this.shutdownPromise = finish() + } + } + // Why: a non-reading authenticated peer must not pin a fenced daemon by + // holding its acknowledgement behind permanent socket backpressure. + timer = setTimeout(start, DaemonServer.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS) + timer.unref() + socket.once('close', start) + socket.once('error', start) + this.pendingShutdownReplies.set(key, { start }) + } + private async preparePtySpawnUnlessCanceled(sessionId: string): Promise { const preparation: PendingPtySpawnPreparation = { canceled: false } const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set() @@ -387,64 +677,80 @@ export class DaemonServer { switch (request.type) { case 'createOrAttach': { + if (this.idleShutdownState !== 'running') { + throw new Error('Daemon temporarily unavailable; reconnect') + } + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + // Why: a control-only replacement cannot own terminal admission or + // erase the prior full client's monotonic retirement request. + throw new Error('Daemon client connection is incomplete; reconnect') + } + this.createOrAttachInFlight++ const p = request.payload - await this.preparePtySpawnUnlessCanceled(p.sessionId) - const result = await this.host.createOrAttach({ - sessionId: p.sessionId, - cols: p.cols, - rows: p.rows, - cwd: p.cwd, - env: p.env, - envToDelete: p.envToDelete, - command: p.command, - startupCommandDelivery: p.startupCommandDelivery, - // Why: daemon RPC payloads are untrusted JSON. Persist only the - // allowlisted enum used for byte routing, never arbitrary identity. - ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), - shellOverride: p.shellOverride, - terminalWindowsWslDistro: p.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, - shellReadySupported: p.shellReadySupported, - historySeed: p.historySeed, - ...(p.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } - : {}), - streamClient: { - onData: (data) => { - // Scan BEFORE enqueue: the batcher may keep-tail drop this - // chunk, but its facts must be captured regardless. - this.transientFactRelay.onSessionData(p.sessionId, data) - const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId) - const isInteractiveOutput = - data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS - this.streamDataBatcher.enqueue(clientId, p.sessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS - }) - }, - onExit: (code) => { - // Why: exit tears down renderer handlers, so it must ride the - // ordered queue behind final output even when the shallow socket - // gate holds that output for a later drain pass. - this.log.log('session-exited', { sessionId: p.sessionId, code }) - this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { - type: 'event', - event: 'exit', - sessionId: p.sessionId, - payload: { code } - }) - this.streamDataBatcher.flush(clientId) - recordDaemonStreamBacklogEvent('sessionExit', { - sessionIdSuffix: p.sessionId.slice(-10) - }) - this.transientFactRelay.onSessionExit(p.sessionId) - this.streamClientIdBySessionId.delete(p.sessionId) - this.lastInputAtBySessionId.delete(p.sessionId) + let result: Awaited> + try { + await this.preparePtySpawnUnlessCanceled(p.sessionId) + result = await this.host.createOrAttach({ + sessionId: p.sessionId, + cols: p.cols, + rows: p.rows, + cwd: p.cwd, + env: p.env, + envToDelete: p.envToDelete, + command: p.command, + startupCommandDelivery: p.startupCommandDelivery, + // Why: daemon RPC payloads are untrusted JSON. Persist only the + // allowlisted enum used for byte routing, never arbitrary identity. + ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), + shellOverride: p.shellOverride, + terminalWindowsWslDistro: p.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, + shellReadySupported: p.shellReadySupported, + historySeed: p.historySeed, + ...(p.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } + : {}), + streamClient: { + onData: (data) => { + // Scan BEFORE enqueue: the batcher may keep-tail drop this + // chunk, but its facts must be captured regardless. + this.transientFactRelay.onSessionData(p.sessionId, data) + const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId) + const isInteractiveOutput = + data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS + this.streamDataBatcher.enqueue(clientId, p.sessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS + }) + }, + onExit: (code) => { + // Why: exit tears down renderer handlers, so it must ride the + // ordered queue behind final output even when the shallow socket + // gate holds that output for a later drain pass. + this.log.log('session-exited', { sessionId: p.sessionId, code }) + this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + type: 'event', + event: 'exit', + sessionId: p.sessionId, + payload: { code } + }) + this.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: p.sessionId.slice(-10) + }) + this.transientFactRelay.onSessionExit(p.sessionId) + this.streamClientIdBySessionId.delete(p.sessionId) + this.lastInputAtBySessionId.delete(p.sessionId) + this.reevaluateIdleShutdown() + } } - } - }) + }) + } finally { + this.createOrAttachInFlight-- + this.reevaluateIdleShutdown() + } this.streamClientIdBySessionId.set(p.sessionId, clientId) // Why an attach-time marker: the adapter resyncs the background set on // a fresh connection, which can precede this attach — main's scan @@ -599,6 +905,35 @@ export class DaemonServer { case 'listSessions': return { sessions: this.host.listSessions() } + case 'shutdownIfIdle': { + const authenticatedClient = this.clients.get(clientId) + const retiring = + authenticatedClient !== undefined && + authenticatedClient.streamSocket !== null && + this.clients.size === 1 && + this.createOrAttachInFlight === 0 && + this.host.listSessions().length === 0 && + [...this.transportSockets].every( + (transport) => + transport === authenticatedClient.controlSocket || + transport === authenticatedClient.streamSocket + ) + if (!retiring) { + return { retiring: false } + } + this.idleShutdownState = 'shutting-down' + this.initialAdoptionDeadlineMs = null + this.retirementRequested = false + this.cancelInitialAdoptionTimer() + // Why: close before acknowledging retirement so no new terminal can + // race between the empty proof and daemon disposal. + const serverClose = this.beginServerClose() + this.deferShutdownUntilReply(clientId, request.id, authenticatedClient.controlSocket, () => + this.finishIdleShutdown(serverClose) + ) + return { retiring: true } + } + case 'getSnapshot': { const snapshotStart = performance.now() const requestedScrollbackRows = request.payload.scrollbackRows @@ -644,11 +979,12 @@ export class DaemonServer { await this.ptySpawnHealthCheck() return { healthy: true } - case 'shutdown': + case 'shutdown': { this.log.log('shutdown', { reason: 'rpc', killSessions: request.payload.killSessions === true }) + const serverClose = this.beginOrdinaryShutdownFence() if (request.payload.killSessions) { try { await this.host.dispose() @@ -661,8 +997,16 @@ export class DaemonServer { }) } } - process.nextTick(() => this.shutdown()) + const controlSocket = this.clients.get(clientId)?.controlSocket + if (controlSocket) { + this.deferShutdownUntilReply(clientId, request.id, controlSocket, () => + this.finishOrdinaryShutdown(serverClose) + ) + } else if (!this.shutdownPromise) { + this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + } return {} + } } throw new Error(`Unknown request type: ${(request as { type: string }).type}`) } diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index f0367319ed5..613b9e6a149 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { DaemonSpawner, getDaemonPidPath, getDaemonSocketPath, - getDaemonTokenPath + getDaemonTokenPath, + restoreClaimedDaemonArtifact } from './daemon-spawner' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' @@ -70,6 +71,20 @@ describe('DaemonSpawner', () => { } describe('ensureRunning', () => { + it('passes the scoped PID path and a fresh launch nonce to the launcher', async () => { + const launcher = vi.fn(async () => ({ shutdown: vi.fn(async () => {}) })) + spawner = new DaemonSpawner({ runtimeDir: dir, launcher }) + + await spawner.ensureRunning() + + expect(launcher).toHaveBeenCalledWith( + getDaemonSocketPath(dir), + getDaemonTokenPath(dir), + getDaemonPidPath(dir), + expect.stringMatching(/^[0-9a-f-]{36}$/) + ) + }) + it('uses protocol-scoped socket and token paths', () => { const socketPath = getDaemonSocketPath(dir) const tokenPath = getDaemonTokenPath(dir) @@ -172,3 +187,51 @@ describe('DaemonSpawner', () => { }) }) }) + +describe('restoreClaimedDaemonArtifact', () => { + it('retains the unique claim when restoration fails without a replacement', () => { + expect( + restoreClaimedDaemonArtifact('/claimed', '/canonical', { + copyExclusive: () => { + throw new Error('injected ENOSPC') + }, + canonicalExists: () => false + }) + ).toBe(false) + }) + + it('retains the unique claim when a failed copy leaves a partial canonical file', () => { + const restoreDir = createTestDir() + const canonicalPath = join(restoreDir, 'partial-canonical') + try { + expect( + restoreClaimedDaemonArtifact('/claimed', canonicalPath, { + copyExclusive: () => { + writeFileSync(canonicalPath, 'partial') + throw Object.assign(new Error('injected ENOSPC'), { code: 'ENOSPC' }) + }, + canonicalExists: () => true + }) + ).toBe(false) + } finally { + rmSync(restoreDir, { recursive: true, force: true }) + } + }) + + it('allows claim cleanup after successful restore or a confirmed replacement', () => { + expect( + restoreClaimedDaemonArtifact('/claimed', '/canonical', { + copyExclusive: () => {}, + canonicalExists: () => false + }) + ).toBe(true) + expect( + restoreClaimedDaemonArtifact('/claimed', '/canonical', { + copyExclusive: () => { + throw Object.assign(new Error('injected EEXIST'), { code: 'EEXIST' }) + }, + canonicalExists: () => true + }) + ).toBe(true) + }) +}) diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index ef54ed9a25d..ba0ea239c67 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -1,4 +1,5 @@ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' +import { constants, copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' @@ -12,14 +13,21 @@ export type DaemonPidFile = { startedAtMs: number | null entryPath?: string appVersion?: string + launchNonce?: string } export type DaemonProcessHandle = { mode?: 'degraded-new-pty-fallback' + releaseAdoptionLease?(): void shutdown(): Promise } -export type DaemonLauncher = (socketPath: string, tokenPath: string) => Promise +export type DaemonLauncher = ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string +) => Promise export type DaemonSpawnerOptions = { runtimeDir: string @@ -32,12 +40,14 @@ export class DaemonSpawner { private handle: DaemonProcessHandle | null = null private socketPath: string private tokenPath: string + private pidPath: string constructor(opts: DaemonSpawnerOptions) { this.runtimeDir = opts.runtimeDir this.launcher = opts.launcher this.socketPath = getDaemonSocketPath(this.runtimeDir) this.tokenPath = getDaemonTokenPath(this.runtimeDir) + this.pidPath = getDaemonPidPath(this.runtimeDir) } async ensureRunning(): Promise { @@ -45,7 +55,9 @@ export class DaemonSpawner { return { socketPath: this.socketPath, tokenPath: this.tokenPath } } - this.handle = await this.launcher(this.socketPath, this.tokenPath) + // Why: a detached daemon may clean up after its parent exits. A unique + // launch identity keeps it from deleting a replacement daemon's PID file. + this.handle = await this.launcher(this.socketPath, this.tokenPath, this.pidPath, randomUUID()) return { socketPath: this.socketPath, tokenPath: this.tokenPath } } @@ -96,3 +108,83 @@ export function getDaemonPidPath(runtimeDir: string, protocolVersion = PROTOCOL_ export function serializeDaemonPidFile(pidFile: DaemonPidFile): string { return JSON.stringify(pidFile) } + +export function unlinkOwnedDaemonPidFile( + pidPath: string, + expectedPid: number, + expectedLaunchNonce: string +): boolean { + return claimAndUnlinkOwnedFile(pidPath, (content) => { + try { + const parsed = JSON.parse(content) as { pid?: unknown; launchNonce?: unknown } + return parsed.pid === expectedPid && parsed.launchNonce === expectedLaunchNonce + } catch { + return false + } + }) +} + +export function unlinkOwnedDaemonTokenFile(tokenPath: string, expectedToken: string): boolean { + return claimAndUnlinkOwnedFile(tokenPath, (content) => content.trim() === expectedToken) +} + +function claimAndUnlinkOwnedFile( + filePath: string, + ownsContent: (content: string) => boolean +): boolean { + const claimedPath = `${filePath}.cleanup-${process.pid}-${randomUUID()}` + try { + // Why: rename claims one exact directory entry before inspection, so a replacement + // installed afterward stays at the canonical path and cannot be unlinked by us. + renameSync(filePath, claimedPath) + } catch { + return false + } + try { + if (ownsContent(readFileSync(claimedPath, 'utf8'))) { + unlinkSync(claimedPath) + return true + } + } catch { + // Restore below when the claimed file cannot be validated as ours. + } + + const restoredOrReplaced = restoreClaimedDaemonArtifact(claimedPath, filePath) + if (restoredOrReplaced) { + try { + unlinkSync(claimedPath) + } catch { + // A uniquely named unowned claim is safer to leave than overwriting a replacement. + } + } + return false +} + +export function restoreClaimedDaemonArtifact( + claimedPath: string, + canonicalPath: string, + operations: { + copyExclusive?: (source: string, target: string) => void + canonicalExists?: (path: string) => boolean + } = {} +): boolean { + const copyExclusive = + operations.copyExclusive ?? + ((source: string, target: string) => copyFileSync(source, target, constants.COPYFILE_EXCL)) + const canonicalExists = operations.canonicalExists ?? existsSync + try { + // Why: exclusive restore never overwrites a newer canonical replacement. + copyExclusive(claimedPath, canonicalPath) + return true + } catch (error) { + // Why: copy failures can leave a partial canonical file. Only EEXIST proves + // another owner had already installed a replacement before our copy. + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EEXIST' && + canonicalExists(canonicalPath) + ) + } +} diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 59b5c998f74..12edb3b8a7c 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createProductionLauncher } from './production-launcher' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' @@ -63,6 +63,20 @@ describe('createProductionLauncher', () => { expect(typeof launcher).toBe('function') }) + it('rejects either ownership argument without its pair before forking', async () => { + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => '/fake/path.js' + }) + + await expect( + launcher(socketPathFor(dir), tokenPathFor(dir), join(dir, 'daemon.pid')) + ).rejects.toThrow('provided together') + await expect( + launcher(socketPathFor(dir), tokenPathFor(dir), undefined, 'launch-a') + ).rejects.toThrow('provided together') + expect(forkMock).not.toHaveBeenCalled() + }) + it('can be used with DaemonSpawner (in-process fallback)', async () => { // Use in-process launcher for testing (same as DaemonSpawner tests) const launcher = async (socketPath: string, tokenPath: string) => { @@ -115,8 +129,9 @@ describe('createProductionLauncher', () => { getDaemonEntryPath: () => join(dir, 'daemon-entry.js') }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready' }) + const pidPath = join(dir, 'daemon.pid') + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a') + handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) const handle = await launch expect(handle.shutdown).toEqual(expect.any(Function)) @@ -125,9 +140,73 @@ describe('createProductionLauncher', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalled() expect(child.unref).toHaveBeenCalled() + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({ + pid: 12345, + startedAtMs: 123_456, + entryPath: join(dir, 'daemon-entry.js'), + launchNonce: 'launch-a' + }) + expect(forkMock).toHaveBeenCalledWith( + join(dir, 'daemon-entry.js'), + expect.arrayContaining(['--pid-record', pidPath, '--launch-nonce', 'launch-a']), + expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) + ) }) - it('removes shutdown exit listener when force-kill timeout settles first', async () => { + it('resolves shutdown only after observing child exit', async () => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + on: vi.fn((event: string, callback: (arg?: unknown) => void) => { + handlers[event]?.push(callback) + return child + }), + once: vi.fn((event: string, callback: (arg?: unknown) => void) => { + handlers[event]?.push(callback) + return child + }), + off: vi.fn((event: string, callback: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return child + }), + kill: vi.fn((signal: NodeJS.Signals) => { + if (signal === 'SIGTERM') { + queueMicrotask(() => { + child.exitCode = 0 + for (const callback of handlers.exit.slice()) { + callback(0) + } + }) + } + return true + }), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + }) + const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) + handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) + const handle = await launch + + await expect(handle.shutdown()).resolves.toBeUndefined() + + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + expect(child.exitCode).toBe(0) + }) + + it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => { vi.useFakeTimers() try { const handlers: Record void)[]> = { @@ -138,6 +217,9 @@ describe('createProductionLauncher', () => { const child = { pid: 12345, killed: false, + connected: false, + exitCode: null, + signalCode: null, on: vi.fn((event: string, cb: (arg?: unknown) => void) => { handlers[event]?.push(cb) return child @@ -150,8 +232,10 @@ describe('createProductionLauncher', () => { handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] return child }), - kill: vi.fn(), - disconnect: vi.fn(), + kill: vi.fn(() => true), + disconnect: vi.fn(() => { + child.connected = false + }), unref: vi.fn() } forkMock.mockReturnValueOnce(child) @@ -161,22 +245,135 @@ describe('createProductionLauncher', () => { }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready' }) + handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) const handle = await launch - const shutdown = handle.shutdown() + const shutdown = expect(handle.shutdown()).rejects.toThrow( + 'Daemon did not exit after SIGKILL' + ) expect(handlers.exit).toHaveLength(1) - await vi.advanceTimersByTimeAsync(5000) + await vi.advanceTimersByTimeAsync(6000) await shutdown expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM') expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') expect(handlers.exit).toHaveLength(0) + expect(child.unref).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } }) + + it('preserves readiness and signaling failures while releasing startup IPC', async () => { + vi.useFakeTimers() + try { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const signalError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + once: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + kill: vi.fn(() => { + throw signalError + }), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + }) + const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) + handlers.message[0]?.({ type: 'ready' }) + + const error = await launch.catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + signalError + ]) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + expect(handlers.message).toHaveLength(0) + expect(handlers.error).toHaveLength(0) + expect(handlers.exit).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('preserves PID publication and cleanup failures', async () => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const signalError = Object.assign(new Error('signal blocked'), { code: 'EPERM' }) + const child = { + pid: 12345, + connected: true, + exitCode: null, + signalCode: null, + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + once: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + kill: vi.fn(() => { + throw signalError + }), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const pidPath = join(dir, 'occupied.pid') + writeFileSync(pidPath, 'occupied') + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + }) + + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') + handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) + + const error = await launch.catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ code: 'EEXIST' }), + signalError + ]) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + }) }) function socketPathFor(dir: string): string { diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index 3fb5d7e792c..b9fe704a41d 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -1,5 +1,10 @@ import { fork, type ChildProcess } from 'node:child_process' -import type { DaemonLauncher, DaemonProcessHandle } from './daemon-spawner' +import { writeFileSync } from 'node:fs' +import { + serializeDaemonPidFile, + type DaemonLauncher, + type DaemonProcessHandle +} from './daemon-spawner' const READY_TIMEOUT_MS = 10_000 @@ -8,17 +13,63 @@ export type ProductionLauncherOptions = { } export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher { - return async (socketPath: string, tokenPath: string): Promise => { + return async ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ): Promise => { + if ((pidPath === undefined) !== (launchNonce === undefined)) { + // Why: partial ownership metadata would launch a v24 daemon that cannot + // prove which PID record it may remove during self-retirement. + throw new Error('Daemon PID path and launch nonce must be provided together') + } const entryPath = opts.getDaemonEntryPath() - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - detached: true, - env: { ...process.env }, - ...(process.platform === 'win32' ? { windowsHide: true } : {}) - }) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + ...(pidPath && launchNonce ? ['--pid-record', pidPath, '--launch-nonce', launchNonce] : []) + ], + { + // Why: detached daemon output is not consumed; ignored streams cannot + // keep Electron alive after the child and IPC channel are unreferenced. + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + detached: true, + env: { ...process.env }, + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + } + ) - await waitForReady(child) + let startedAtMs: number + try { + startedAtMs = await waitForReady(child) + } catch (error) { + return rejectAfterChildCleanup(child, error) + } + if (pidPath && launchNonce) { + if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { + return rejectAfterChildCleanup(child, new Error('Daemon readiness identity is incomplete')) + } + try { + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: child.pid as number, + startedAtMs, + entryPath, + launchNonce + }), + { mode: 0o600, flag: 'wx' } + ) + } catch (error) { + return rejectAfterChildCleanup(child, error) + } + } // Unref so the Electron process can exit without waiting for the daemon child.unref() @@ -30,7 +81,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } } -function waitForReady(child: ChildProcess): Promise { +function waitForReady(child: ChildProcess): Promise { return new Promise((resolve, reject) => { let timeout: ReturnType | undefined let settled = false @@ -42,15 +93,12 @@ function waitForReady(child: ChildProcess): Promise { child.off('error', onError) child.off('exit', onExit) } - function fail(error: Error, killChild = false): void { + function fail(error: Error): void { if (settled) { return } settled = true cleanupStartupListeners() - if (killChild) { - child.kill('SIGTERM') - } reject(error) } function onMessage(msg: unknown): void { @@ -58,11 +106,16 @@ function waitForReady(child: ChildProcess): Promise { if (settled) { return } + const startedAtMs = (msg as { startedAtMs?: unknown }).startedAtMs + if (typeof startedAtMs !== 'number' || !Number.isFinite(startedAtMs) || startedAtMs <= 0) { + fail(new Error('Daemon readiness identity is incomplete')) + return + } settled = true // Why: the daemon is detached after readiness, so startup listeners // must not keep the child process closure alive for the daemon lifetime. cleanupStartupListeners() - resolve() + resolve(startedAtMs) } } function onError(err: Error): void { @@ -73,7 +126,7 @@ function waitForReady(child: ChildProcess): Promise { } timeout = setTimeout(() => { - fail(new Error('Daemon failed to signal readiness within timeout'), true) + fail(new Error('Daemon failed to signal readiness within timeout')) }, READY_TIMEOUT_MS) child.on('message', onMessage) @@ -82,35 +135,88 @@ function waitForReady(child: ChildProcess): Promise { }) } -function shutdownChild(child: ChildProcess): Promise { - return new Promise((resolve) => { - if (child.killed) { - resolve() +async function shutdownChild(child: ChildProcess): Promise { + try { + if ( + (child.exitCode !== null && child.exitCode !== undefined) || + (child.signalCode !== null && child.signalCode !== undefined) + ) { return } - - let settled = false - let timeout: ReturnType - function finish(): void { - if (settled) { - return + await new Promise((resolve, reject) => { + let settled = false + let timeout: ReturnType + let forceTimeout: ReturnType | undefined + function finish(error?: unknown): void { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (forceTimeout) { + clearTimeout(forceTimeout) + } + child.off('exit', onExit) + if (error) { + reject(error) + } else { + resolve() + } } - settled = true - clearTimeout(timeout) - child.off('exit', onExit) - resolve() + + function onExit(): void { + finish() + } + + timeout = setTimeout(() => { + try { + if (child.kill('SIGKILL') === false) { + finish(new Error('Failed to deliver SIGKILL to daemon')) + return + } + } catch (error) { + finish(isNoSuchProcessError(error) ? undefined : error) + return + } + // Why: signal delivery is not process exit; keep the listener for one + // bounded interval before releasing launcher-owned handles. + if (!settled) { + forceTimeout = setTimeout( + () => finish(new Error('Daemon did not exit after SIGKILL')), + 1000 + ) + } + }, 5000) + + child.once('exit', onExit) + try { + if (child.kill('SIGTERM') === false) { + finish(new Error('Failed to deliver SIGTERM to daemon')) + } + } catch (error) { + finish(isNoSuchProcessError(error) ? undefined : error) + } + }) + } finally { + if (child.connected) { + child.disconnect() } + child.unref() + } +} - function onExit(): void { - finish() - } +async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown): Promise { + try { + await shutdownChild(child) + } catch (cleanupError) { + throw new AggregateError( + [launchError, cleanupError], + 'Daemon launch and child cleanup both failed' + ) + } + throw launchError +} - timeout = setTimeout(() => { - child.kill('SIGKILL') - finish() - }, 5000) - - child.once('exit', onExit) - child.kill('SIGTERM') - }) +function isNoSuchProcessError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 63c5edc577b..0daaf0ac0cf 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -16,10 +16,11 @@ import type { TuiAgent } from '../../shared/types' // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 23 +export const PROTOCOL_VERSION = 24 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 +export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -80,19 +81,7 @@ export type { TerminalCheckpointFile } from './daemon-checkpoint-file' // ─── NDJSON Protocol Messages ─────────────────────────────────────── // Hello handshake (first message on each socket) -export type HelloMessage = { - type: 'hello' - version: number - token: string - clientId: string - role: 'control' | 'stream' -} - -export type HelloResponse = { - type: 'hello' - ok: boolean - error?: string -} +export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daemon-hello-protocol' // ─── RPC Requests (Client → Daemon, on control socket) ───────────── @@ -211,6 +200,11 @@ export type ListSessionsRequest = { type: 'listSessions' } +export type ShutdownIfIdleRequest = { + id: string + type: 'shutdownIfIdle' +} + export type DetachRequest = { id: string type: 'detach' @@ -330,6 +324,7 @@ export type DaemonRequest = | KillRequest | SignalRequest | ListSessionsRequest + | ShutdownIfIdleRequest | DetachRequest | GetCwdRequest | GetForegroundProcessRequest @@ -368,6 +363,10 @@ export type ListSessionsResult = { sessions: SessionInfo[] } +export type ShutdownIfIdleResult = { + retiring: boolean +} + export type SystemResolverHealth = 'healthy' | 'unhealthy' | 'unknown' export type SystemResolverHealthResult = { diff --git a/tests/e2e/daemon-lifecycle-retirement.spec.ts b/tests/e2e/daemon-lifecycle-retirement.spec.ts new file mode 100644 index 00000000000..f522088998e --- /dev/null +++ b/tests/e2e/daemon-lifecycle-retirement.spec.ts @@ -0,0 +1,241 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { build } from 'esbuild' +import { expect, test } from '@playwright/test' +import { DaemonClient } from '../../src/main/daemon/client' +import { DaemonPtyAdapter } from '../../src/main/daemon/daemon-pty-adapter' +import { + getDaemonPidPath, + getDaemonSocketPath, + getDaemonTokenPath +} from '../../src/main/daemon/daemon-spawner' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' + +type FixtureDaemon = { + child: ChildProcess + protocolVersion: number + socketPath: string + tokenPath: string + pidPath: string +} + +async function waitFor( + label: string, + predicate: () => boolean | Promise, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!(await predicate())) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${label}`) + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } +} + +async function launchFixture( + entryPath: string, + daemonDir: string, + protocolVersion: number +): Promise { + const socketPath = getDaemonSocketPath(daemonDir, protocolVersion) + const tokenPath = getDaemonTokenPath(daemonDir, protocolVersion) + const pidPath = getDaemonPidPath(daemonDir, protocolVersion) + const launchNonce = randomUUID() + const child = fork( + entryPath, + [ + '--protocol', + String(protocolVersion), + '--socket', + socketPath, + '--token', + tokenPath, + ...(protocolVersion >= PROTOCOL_VERSION + ? ['--pid-record', pidPath, '--launch-nonce', launchNonce] + : []) + ], + { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { + ...process.env, + NODE_PATH: [path.join(process.cwd(), 'node_modules'), process.env.NODE_PATH] + .filter(Boolean) + .join(path.delimiter) + } + } + ) + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr = `${stderr}${String(chunk)}`.slice(-8_192) + }) + try { + await new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timeout) + child.off('message', onMessage) + child.off('error', onError) + child.off('exit', onExit) + } + const onMessage = (message: unknown): void => { + if ((message as { type?: unknown }).type !== 'ready') { + return + } + cleanup() + resolve() + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const onExit = (code: number | null): void => { + cleanup() + reject(new Error(`Lifecycle fixture exited with ${code}: ${stderr.trim()}`)) + } + const timeout = setTimeout(() => { + cleanup() + reject(new Error('Lifecycle fixture startup timed out')) + }, 10_000) + child.on('message', onMessage) + child.on('error', onError) + child.on('exit', onExit) + }) + } catch (error) { + try { + await stopChild(child, `fixture v${protocolVersion}`) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Fixture startup and cleanup both failed') + } + throw error + } + return { child, protocolVersion, socketPath, tokenPath, pidPath } +} + +async function stopChild(child: ChildProcess, label: string): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + child.kill('SIGTERM') + try { + await waitFor( + `${label} graceful exit`, + () => child.exitCode !== null || child.signalCode !== null, + 3_000 + ) + return + } catch { + child.kill('SIGKILL') + } + await waitFor( + `${label} forced exit`, + () => child.exitCode !== null || child.signalCode !== null, + 3_000 + ) +} + +async function stopFixture(fixture: FixtureDaemon): Promise { + await stopChild(fixture.child, `fixture v${fixture.protocolVersion}`) +} + +test('v22 stays reattachable while v24 retires after its last empty client disconnects', async () => { + const rootDir = mkdtempSync(path.join(tmpdir(), 'orca-daemon-lifecycle-')) + const daemonDir = path.join(rootDir, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + const entryPath = path.join(rootDir, 'daemon-lifecycle-entry.cjs') + const fixtures: FixtureDaemon[] = [] + let testError: unknown + + try { + await build({ + entryPoints: [path.join(process.cwd(), 'tests/e2e/fixtures/daemon-lifecycle-entry.ts')], + outfile: entryPath, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + external: ['node-pty'], + logLevel: 'silent' + }) + + const legacy = await launchFixture(entryPath, daemonDir, 22) + fixtures.push(legacy) + const legacyClient = new DaemonClient({ + socketPath: legacy.socketPath, + tokenPath: legacy.tokenPath, + protocolVersion: 22 + }) + await legacyClient.ensureConnected() + await expect( + legacyClient.request('createOrAttach', { sessionId: 'legacy-live', cols: 80, rows: 24 }) + ).resolves.toMatchObject({ isNew: true }) + legacyClient.disconnect() + + const current = await launchFixture(entryPath, daemonDir, PROTOCOL_VERSION) + fixtures.push(current) + const reattachClient = new DaemonClient({ + socketPath: legacy.socketPath, + tokenPath: legacy.tokenPath, + protocolVersion: 22 + }) + await reattachClient.ensureConnected() + await expect( + reattachClient.request('createOrAttach', { + sessionId: 'legacy-live', + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ isNew: false }) + reattachClient.disconnect() + + const currentAdapter = new DaemonPtyAdapter({ + socketPath: current.socketPath, + tokenPath: current.tokenPath + }) + const secondCurrentClient = new DaemonClient({ + socketPath: current.socketPath, + tokenPath: current.tokenPath + }) + await secondCurrentClient.ensureConnected() + + // Why: failed adoption may overlap another authenticated app client, so + // only daemon-owned idle retirement can safely decide whether to exit. + await currentAdapter.disconnectOnly() + expect(current.child.exitCode).toBeNull() + await expect(secondCurrentClient.request('listSessions', undefined)).resolves.toEqual({ + sessions: [] + }) + secondCurrentClient.disconnect() + + await waitFor('v24 process exit', () => current.child.exitCode !== null) + expect(existsSync(current.tokenPath)).toBe(false) + expect(existsSync(current.pidPath)).toBe(false) + if (process.platform !== 'win32') { + expect(existsSync(current.socketPath)).toBe(false) + } + expect(legacy.child.exitCode).toBeNull() + } catch (error) { + testError = error + } + + const results = await Promise.allSettled(fixtures.map((fixture) => stopFixture(fixture))) + const cleanupErrors = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + try { + rmSync(rootDir, { recursive: true, force: true }) + } catch (error) { + cleanupErrors.push(error) + } + if (testError !== undefined && cleanupErrors.length > 0) { + throw new AggregateError([testError, ...cleanupErrors], 'Lifecycle test and cleanup failed') + } + if (testError !== undefined) { + throw testError + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, 'Lifecycle fixture cleanup failed') + } +}) diff --git a/tests/e2e/fixtures/daemon-lifecycle-entry.ts b/tests/e2e/fixtures/daemon-lifecycle-entry.ts new file mode 100644 index 00000000000..0e09897b0c0 --- /dev/null +++ b/tests/e2e/fixtures/daemon-lifecycle-entry.ts @@ -0,0 +1,122 @@ +import process from 'node:process' +import { writeFileSync } from 'node:fs' +import { startDaemon, type DaemonHandle } from '../../../src/main/daemon/daemon-main' +import { serializeDaemonPidFile } from '../../../src/main/daemon/daemon-spawner' +import type { SubprocessHandle } from '../../../src/main/daemon/session' + +type FixtureArgs = { + protocolVersion: number + socketPath: string + tokenPath: string + pidPath?: string + launchNonce?: string +} + +function parseArgs(argv: string[]): FixtureArgs { + const values = new Map() + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (!key || !value) { + throw new Error('Lifecycle fixture arguments must be key/value pairs') + } + values.set(key, value) + } + const protocolVersion = Number(values.get('--protocol')) + const socketPath = values.get('--socket') + const tokenPath = values.get('--token') + const pidPath = values.get('--pid-record') + const launchNonce = values.get('--launch-nonce') + if ( + !Number.isInteger(protocolVersion) || + protocolVersion < 1 || + !socketPath || + !tokenPath || + Boolean(pidPath) !== Boolean(launchNonce) + ) { + throw new Error('Invalid lifecycle fixture arguments') + } + return { + protocolVersion, + socketPath, + tokenPath, + ...(pidPath && launchNonce ? { pidPath, launchNonce } : {}) + } +} + +function createFixtureSubprocess(): SubprocessHandle { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + let exited = false + const exit = (code: number): void => { + if (exited) { + return + } + exited = true + onExit?.(code) + } + return { + pid: process.pid, + getForegroundProcess: () => null, + write: (data) => onData?.(data), + resize: () => {}, + kill: () => exit(0), + forceKill: () => exit(137), + signal: () => {}, + onData: (callback) => { + onData = callback + }, + onExit: (callback) => { + onExit = callback + }, + dispose: () => exit(0) + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + const startedAtMs = Date.now() - process.uptime() * 1000 + let daemon: DaemonHandle | null = await startDaemon({ + protocolVersion: args.protocolVersion, + socketPath: args.socketPath, + tokenPath: args.tokenPath, + ...(args.pidPath ? { pidPath: args.pidPath } : {}), + ...(args.launchNonce ? { launchNonce: args.launchNonce } : {}), + startedAtMs, + spawnSubprocess: () => createFixtureSubprocess(), + onIdleShutdown: () => process.exit(0) + }) + if (args.pidPath && args.launchNonce) { + writeFileSync( + args.pidPath, + serializeDaemonPidFile({ + pid: process.pid, + startedAtMs, + launchNonce: args.launchNonce + }), + { mode: 0o600, flag: 'wx' } + ) + } + + let shuttingDown = false + const shutdown = async (): Promise => { + if (shuttingDown) { + return + } + shuttingDown = true + try { + await daemon?.shutdown() + daemon = null + } finally { + process.exit(0) + } + } + process.on('SIGTERM', () => void shutdown()) + process.on('SIGINT', () => void shutdown()) + process.send?.({ type: 'ready', pid: process.pid, startedAtMs }) +} + +void main().catch((error) => { + console.error(error) + process.exit(1) +}) From d628c9300eae658497435bcbeabebad7566883cb Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 19 Jul 2026 22:54:39 -0400 Subject: [PATCH 02/35] fix(terminal): harden revealed inline-TUI convergence (#9503) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../terminal-pane/pty-connection.test.ts | 256 ++++++- .../terminal-pane/pty-connection.ts | 72 +- .../components/terminal-pane/replay-guard.ts | 3 + .../pane-fit-continuation-retry.ts | 87 +++ .../src/lib/pane-manager/pane-fit.test.ts | 169 +++++ src/renderer/src/lib/pane-manager/pane-fit.ts | 56 +- ...-terminal-foreground-render-settle.test.ts | 83 ++ .../pane-terminal-foreground-render-settle.ts | 33 +- .../codex-inline-live-block-fixture.cjs | 139 ++++ ...inal-inline-tui-reveal-convergence.spec.ts | 718 ++++++++++++++++++ 10 files changed, 1597 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-fit.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts create mode 100644 tests/e2e/fixtures/codex-inline-live-block-fixture.cjs create mode 100644 tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1c9323dd0a9..3f05c9a5371 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5953,7 +5953,8 @@ describe('connectPanePty', () => { reattach.resolve() await flushAsyncTicks() - expect(transport.resize).toHaveBeenLastCalledWith(65, 63) + expect(transport.resize).toHaveBeenCalledWith(65, 63) + expect(transport.resize).toHaveBeenLastCalledWith(65, 63, { claim: true }) }) it('adopts a live eager PTY and withholds snapshots after its renderer dies', async () => { @@ -7404,7 +7405,10 @@ describe('connectPanePty', () => { const blankViewport = buildFreshShellViewportBlankingSequence(destinationRows) expect(pane.terminal.resize).toHaveBeenCalledWith(recoveredCols, recoveredRows) expect(transport.resize).not.toHaveBeenCalledWith(recoveredCols, recoveredRows) - expect(transport.resize).toHaveBeenLastCalledWith(destinationCols, destinationRows) + expect(transport.resize).toHaveBeenCalledWith(destinationCols, destinationRows) + expect(transport.resize).toHaveBeenLastCalledWith(destinationCols, destinationRows, { + claim: true + }) expect(written).toContain(viewportClear) expect(written).not.toContain('\x1b[2J\x1b[3J\x1b[H') expect(written).toEqual( @@ -8617,6 +8621,161 @@ describe('connectPanePty', () => { expect(liveIndex).toBeGreaterThan(snapshotIndex) }) + it('re-enforces follow intent after deferred reattach live output parses', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + callbacks?.onData?.('post-snapshot-live') + return { id: sessionId, snapshot: 'authoritative-snapshot' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 100 + markTerminalFollowOutput(pane.terminal) + const parseCallbacks: (() => void)[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + parseCallbacks.push(() => { + if (data === 'post-snapshot-live') { + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 100 + } + callback?.() + }) + }) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + for (let index = 0; index < 30; index += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + if (parseCallbacks.length === 0 && index > 5) { + break + } + } + + expect(pane.terminal.buffer.active.viewportY).toBe(200) + }) + + it('does not steal a newer user pin while deferred reattach output settles', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + callbacks?.onData?.('post-snapshot-live') + return { id: sessionId, snapshot: 'authoritative-snapshot' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 100 + markTerminalFollowOutput(pane.terminal) + const parseCallbacks: { data: string; run: () => void }[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + parseCallbacks.push({ + data, + run: () => { + if (data === 'post-snapshot-live') { + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 100 + } + callback?.() + } + }) + }) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + for (let index = 0; index < 40; index += 1) { + const pending = parseCallbacks.shift() + pending?.run() + await flushAsyncTicks(4) + if (pending?.data === 'post-snapshot-live') { + pane.terminal.buffer.active.viewportY = 150 + markTerminalPinnedViewport(pane.terminal) + } + if (parseCallbacks.length === 0 && index > 8) { + break + } + } + + expect(pane.terminal.buffer.active.viewportY).toBe(150) + }) + + it('does not enforce a deferred viewport after the pane becomes hidden', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + callbacks?.onData?.('post-snapshot-live') + return { id: sessionId, snapshot: 'authoritative-snapshot' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 100 + markTerminalFollowOutput(pane.terminal) + const parseCallbacks: { data: string; run: () => void }[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + parseCallbacks.push({ + data, + run: () => { + if (data === 'post-snapshot-live') { + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 100 + } + callback?.() + } + }) + }) + const deps = createDeps({ + isVisibleRef: { current: true }, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + for (let index = 0; index < 40; index += 1) { + const pending = parseCallbacks.shift() + pending?.run() + await flushAsyncTicks(4) + if (pending?.data === 'post-snapshot-live') { + ;(deps.isVisibleRef as { current: boolean }).current = false + } + if (parseCallbacks.length === 0 && index > 8) { + break + } + } + + expect(pane.terminal.buffer.active.viewportY).toBe(100) + }) + it('does not fresh-spawn after a dead deferred session delivers its buffered exit', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') @@ -11777,6 +11936,99 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('slices abandoned pending chunks against a replay that already painted', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const snapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + pendingDeliveryStartSeq: number + }>() + getMainBufferSnapshot.mockReturnValue(snapshot.promise) + const hidden = 'hidden-codex-output\r\n' + const coveredLive = 'LIVE_DUP_LINE\r\n' + const afterAbandon = 'AFTER_ABANDON\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + + vi.useFakeTimers() + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(coveredLive, { + seq: hidden.length + coveredLive.length, + rawLength: coveredLive.length + }) + await flushAsyncTicks(4) + + const heldCallbacks: (() => void)[] = [] + pane.terminal.write.mockImplementation(function write( + data: string, + callback?: () => void + ): void { + if (callback) { + heldCallbacks.push(callback) + } + void data + }) + snapshot.resolve({ + data: 'SNAP_STATE\r\n', + cols: 100, + rows: 30, + seq: hidden.length + coveredLive.length, + pendingDeliveryStartSeq: 0 + }) + await flushAsyncTicks(6) + + vi.advanceTimersByTime(750) + await flushAsyncTicks(10) + + const writtenAfterAbandon = pane.terminal.write.mock.calls.map(([data]) => data as string) + expect(writtenAfterAbandon.join('')).not.toContain('LIVE_DUP_LINE') + + pane.terminal.write.mockClear() + capturedDataCallback.current?.(coveredLive, { + seq: hidden.length + coveredLive.length, + rawLength: coveredLive.length + }) + await flushAsyncTicks(4) + expect(pane.terminal.write.mock.calls.map(([data]) => data as string).join('')).not.toContain( + 'LIVE_DUP_LINE' + ) + + capturedDataCallback.current?.(afterAbandon, { + seq: hidden.length + coveredLive.length + afterAbandon.length, + rawLength: afterAbandon.length + }) + await flushAsyncTicks(4) + expect(pane.terminal.write.mock.calls.map(([data]) => data as string).join('')).toContain( + 'AFTER_ABANDON' + ) + + heldCallbacks.forEach((callback) => callback()) + disposable.dispose() + }) + it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 17fb6225ba3..18066ae75d2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -131,6 +131,7 @@ import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { + enforceTerminalCurrentScrollIntent, getTerminalScrollIntentKind, markTerminalFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent' @@ -4907,7 +4908,8 @@ export function connectPanePty( // scheduler's deferred drain cannot land older bytes on top of the replay. flushTerminalOutput(pane.terminal) replayIntoTerminal(pane, deps.replayingPanesRef, data, { - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously + shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously, + shouldReleaseRenderPause: () => deps.isVisibleRef.current }) } @@ -4916,7 +4918,8 @@ export function connectPanePty( // merely after the write was queued. flushTerminalOutput(pane.terminal) return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data, { - shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously + shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously, + shouldReleaseRenderPause: () => deps.isVisibleRef.current }) } @@ -5215,6 +5218,12 @@ export function connectPanePty( let hiddenOutputRestoreGeneration = 0 // Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS). let hiddenOutputRestoreFloodSuppressedUntil = 0 + // Why: queued replay writes still paint after deadline abandonment; the + // fallback drain must not write snapshot-covered live bytes a second time. + let hiddenOutputRestoreReplayingSnapshot: { + seq?: number + pendingDeliveryStartSeq?: number + } | null = null let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null // Why: after a snapshot restore, main can still drain ACK-backlog chunks // whose bytes the snapshot already covers — writing them unguarded @@ -6311,6 +6320,8 @@ export function connectPanePty( ? [] : hiddenOutputRestorePendingChunks.slice() const hadPendingOverflow = hiddenOutputRestorePendingOverflow + const replayingSnapshot = hiddenOutputRestoreReplayingSnapshot + hiddenOutputRestoreReplayingSnapshot = null hiddenOutputRestoreGeneration += 1 if ( hiddenOutputSnapshotScrollRestore?.valid && @@ -6346,7 +6357,21 @@ export function connectPanePty( if (hadPendingOverflow) { return } - const pendingData = pendingChunks.map((chunk) => chunk.data).join('') + const replayedSeq = typeof replayingSnapshot?.seq === 'number' ? replayingSnapshot.seq : null + let pendingData = '' + for (const chunk of pendingChunks) { + const sliced = + replayedSeq === null ? chunk.data : getChunkDataAfterSnapshot(chunk, replayedSeq) + pendingData += sliced ?? chunk.data + } + if (replayingSnapshot && replayedSeq !== null) { + setRestoredSnapshotBaseline(expectedPtyId, replayingSnapshot) + for (const chunk of pendingChunks) { + if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { + restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) + } + } + } if (pendingData) { writePtyOutputToXterm(pendingData, true) } @@ -6391,6 +6416,7 @@ export function connectPanePty( resetHiddenRendererRiskState() hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null + hiddenOutputRestoreReplayingSnapshot = null hiddenOutputRestoreGeneration += 1 } @@ -6482,6 +6508,7 @@ export function connectPanePty( cols: number rows: number seq?: number + pendingDeliveryStartSeq?: number alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string @@ -6517,6 +6544,14 @@ export function connectPanePty( return } scrollRestore.started = true + if (typeof snapshot.seq === 'number') { + hiddenOutputRestoreReplayingSnapshot = { + seq: snapshot.seq, + ...(typeof snapshot.pendingDeliveryStartSeq === 'number' + ? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq } + : {}) + } + } discardTerminalOutput(pane.terminal) if ( hasSnapshotDimensions && @@ -6613,7 +6648,7 @@ export function connectPanePty( } } }, - { shouldContinue: isCurrentRestore } + { shouldContinue: isCurrentRestore, retryIfUnmeasurable: true } ) pendingHiddenSnapshotFit = fit try { @@ -6783,6 +6818,7 @@ export function connectPanePty( // still draining from main's ACK backlog below that point are // duplicates the dataCallback reconciliation must suppress. setRestoredSnapshotBaseline(currentPtyId, snapshot) + hiddenOutputRestoreReplayingSnapshot = null const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded hiddenOutputRestoreFreshSnapshotNeeded = false const drainOutcome = drainPendingLiveChunksAfterSnapshot(snapshot.seq) @@ -7162,6 +7198,7 @@ export function connectPanePty( // Why: createOrAttach snapshots precede bytes emitted before its IPC // reply. Paint the authoritative replay first, then admit those live // chunks so the replay clear cannot erase newer output. + let deliveredDeferredChunks = 0 for (const chunk of chunks) { if ( chunk.ptyId !== currentPtyId || @@ -7171,6 +7208,23 @@ export function connectPanePty( continue } dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + deliveredDeferredChunks += 1 + } + if (deliveredDeferredChunks > 0) { + // Why: replay restores the viewport before these newer bytes parse; + // settle the bounded deferred slice, then apply the latest user intent. + flushTerminalOutput(pane.terminal, { maxChars: MAX_DEFERRED_REATTACH_LIVE_CHARS }) + void waitForTerminalReplayWritesParsed(pane.terminal).then(() => { + if ( + disposed || + !deps.isVisibleRef.current || + transport.getPtyId() !== currentPtyId || + transportStreamGeneration !== currentGeneration + ) { + return + } + enforceTerminalCurrentScrollIntent(pane.terminal) + }) } } @@ -7472,16 +7526,22 @@ export function connectPanePty( window.api.pty.signal(reattachPtyId, 'SIGWINCH') } }, - { shouldContinue: isCurrentReattachPayload } + { shouldContinue: isCurrentReattachPayload, retryIfUnmeasurable: true } ) pendingReattachFit = fit + let fitCompleted = false try { - await fit.completion + fitCompleted = await fit.completion } finally { if (pendingReattachFit === fit) { pendingReattachFit = null } } + if (fitCompleted && isCurrentReattachPayload() && deps.isVisibleRef.current) { + // Why: reattach resize is fire-and-forget; verify the provider's + // applied grid while this reveal still owns the visible pane. + ptySizeReassertion.request({ fit: false }) + } } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { window.api.pty.signal(reattachPtyId, 'SIGWINCH') } diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 6711390d851..b7e43ff82cb 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -64,6 +64,7 @@ const REPLAY_GUARD_STALL_CHECK_MS = 10_000 type ReplayTerminalOptions = { shouldRefreshViewportSynchronously?: () => boolean + shouldReleaseRenderPause?: () => boolean stallCheckMs?: number } @@ -208,6 +209,7 @@ export function replayIntoTerminal( forceViewportRefresh: true, followupViewportRefresh: true, shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, + shouldReleaseRenderPause: options.shouldReleaseRenderPause, onParsed: guardCallbacks.onParsed, onWriteFailure: guardCallbacks.onWriteFailure }) @@ -242,6 +244,7 @@ export function replayIntoTerminalAsync( forceViewportRefresh: true, followupViewportRefresh: true, shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, + shouldReleaseRenderPause: options.shouldReleaseRenderPause, onParsed: guardCallbacks.onParsed, onWriteFailure: guardCallbacks.onWriteFailure }) diff --git a/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts new file mode 100644 index 00000000000..8567ad44884 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts @@ -0,0 +1,87 @@ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import type { ManagedPane } from './pane-manager-types' + +const MAX_RETRY_FRAMES = 40 +const LAYOUT_SETTLE_MS = 16 + +type RetrySchedule = { cancel: () => void } + +type RetryState = { + attempts: number + schedule: RetrySchedule | null + retry: () => boolean + onExhausted: () => void +} + +const retryByPane = new WeakMap() + +function scheduleRetryTick(run: () => void): RetrySchedule { + if (typeof requestAnimationFrame === 'function') { + let cancelled = false + let timer: ReturnType | null = null + const rafId = requestAnimationFrame(() => { + if (!cancelled) { + // Why: FitAddon must observe committed CSS, and synchronous rAF test + // shims must not recursively consume the whole retry budget inline. + timer = setTimeout(run, LAYOUT_SETTLE_MS) + } + }) + return { + cancel: () => { + cancelled = true + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(rafId) + } + if (timer !== null) { + clearTimeout(timer) + } + } + } + } + const timer = setTimeout(run, LAYOUT_SETTLE_MS) + return { cancel: () => clearTimeout(timer) } +} + +export function clearPaneFitContinuationRetry(pane: ManagedPane): void { + const state = retryByPane.get(pane) + if (!state) { + return + } + retryByPane.delete(pane) + state.schedule?.cancel() + state.schedule = null +} + +export function armPaneFitContinuationRetry( + pane: ManagedPane, + callbacks: { retry: () => boolean; onExhausted: () => void } +): void { + const state = retryByPane.get(pane) ?? { + attempts: 0, + schedule: null, + ...callbacks + } + state.retry = callbacks.retry + state.onExhausted = callbacks.onExhausted + retryByPane.set(pane, state) + if (state.schedule) { + return + } + state.schedule = scheduleRetryTick(() => { + state.schedule = null + if (state.retry()) { + clearPaneFitContinuationRetry(pane) + return + } + state.attempts += 1 + if (state.attempts >= MAX_RETRY_FRAMES) { + recordRendererCrashBreadcrumb('terminal_safe_fit_retry_exhausted', { + paneId: pane.id + }) + clearPaneFitContinuationRetry(pane) + state.onExhausted() + return + } + armPaneFitContinuationRetry(pane, state) + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts new file mode 100644 index 00000000000..5857349e3a6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import type { ManagedPane, ScrollState } from './pane-manager-types' +import { safeFit, safeFitAndThen } from './pane-fit' + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +let nextRafId = 1 +let pendingRafs = new Map() + +function flushAnimationFrames(timestamp = 16): void { + const callbacks = Array.from(pendingRafs.values()) + pendingRafs = new Map() + for (const callback of callbacks) { + callback(timestamp) + } +} + +function createPane(options: { + rect: { width: number; height: number } + proposed?: () => { cols: number; rows: number } | undefined +}): ManagedPane & { setRect: (rect: { width: number; height: number }) => void } { + let rect = options.rect + const leafId = '22222222-2222-4222-8222-222222222222' + const pane = { + id: 7, + leafId, + stablePaneId: leafId, + terminal: { cols: 80, rows: 24 }, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: rect.width, height: rect.height }) + }, + xtermContainer: { + getBoundingClientRect: () => ({ width: rect.width, height: rect.height }) + }, + fitAddon: { + fit: vi.fn(), + proposeDimensions: vi.fn(options.proposed ?? (() => ({ cols: 132, rows: 40 }))) + }, + serializeAddon: {}, + searchAddon: {}, + pendingSplitScrollState: null as ScrollState | null, + setRect: (next: { width: number; height: number }) => { + rect = next + } + } + return pane as unknown as ManagedPane & { + setRect: (rect: { width: number; height: number }) => void + } +} + +describe('safeFitAndThen unmeasurable-pane retry', () => { + beforeEach(() => { + vi.useFakeTimers() + nextRafId = 1 + pendingRafs = new Map() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const id = nextRafId++ + pendingRafs.set(id, callback) + return id + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((id: number) => { + pendingRafs.delete(id) + }) + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('runs the continuation once reveal layout becomes measurable', async () => { + const pane = createPane({ rect: { width: 0, height: 0 } }) + const continuation = vi.fn() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + pane.setRect({ width: 800, height: 600 }) + flushAnimationFrames() + vi.advanceTimersByTime(16) + + expect(continuation).toHaveBeenCalledTimes(1) + await expect(handle.completion).resolves.toBe(true) + }) + + it('cancels its scheduled frame with the continuation', async () => { + const pane = createPane({ rect: { width: 0, height: 0 } }) + const continuation = vi.fn() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + handle.cancel() + pane.setRect({ width: 800, height: 600 }) + flushAnimationFrames() + + expect(continuation).not.toHaveBeenCalled() + await expect(handle.completion).resolves.toBe(false) + }) + + it('does not retry a stale restore', async () => { + const pane = createPane({ rect: { width: 0, height: 0 } }) + const continuation = vi.fn() + let current = true + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + shouldContinue: () => current, + retryIfUnmeasurable: true + }) + current = false + pane.setRect({ width: 800, height: 600 }) + flushAnimationFrames() + vi.advanceTimersByTime(16) + + expect(continuation).not.toHaveBeenCalled() + await expect(handle.completion).resolves.toBe(false) + }) + + it('still flushes through an ordinary external fit', async () => { + const pane = createPane({ rect: { width: 0, height: 0 } }) + const continuation = vi.fn() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation) + pane.setRect({ width: 800, height: 600 }) + flushAnimationFrames() + vi.advanceTimersByTime(16) + expect(continuation).not.toHaveBeenCalled() + + safeFit(pane) + + expect(continuation).toHaveBeenCalledTimes(1) + await expect(handle.completion).resolves.toBe(true) + }) + + it('resolves failure after the bounded frame budget instead of hanging reattach', async () => { + const pane = createPane({ rect: { width: 0, height: 0 } }) + const continuation = vi.fn() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + for (let frame = 0; frame < 40; frame += 1) { + flushAnimationFrames(frame * 16) + vi.advanceTimersByTime(16) + } + + expect(continuation).not.toHaveBeenCalled() + expect(recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_safe_fit_retry_exhausted', + { paneId: 7 } + ) + await expect(handle.completion).resolves.toBe(false) + + pane.setRect({ width: 800, height: 600 }) + safeFit(pane) + expect(continuation).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index 573ba6ab7ed..e555a478031 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -1,5 +1,9 @@ import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' import { getFitOverrideForPty } from './mobile-fit-overrides' +import { + armPaneFitContinuationRetry, + clearPaneFitContinuationRetry +} from './pane-fit-continuation-retry' import { captureTerminalStructuralScrollIntent, isTerminalStructuralScrollIntentCurrent, @@ -163,6 +167,7 @@ function settlePendingSafeFitContinuation( operations.delete(operationKey) if (operations.size === 0) { pendingSafeFitContinuations.delete(pane) + clearPaneFitContinuationRetry(pane) } pending.resolve(completed) } @@ -192,11 +197,52 @@ export function safeFit(pane: ManagedPane): boolean { // Why: replay transactions may be waiting for renderer dimensions; any // successful ordinary fit is the event that makes their PTY grid authoritative. flushPendingSafeFitContinuations(pane) + clearPaneFitContinuationRetry(pane) } return completed } +function pruneStaleSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of operations) { + if (!pending.shouldContinue()) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + } +} + +function failPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of Array.from(operations.entries())) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } +} + +function armSafeFitContinuationRetry(pane: ManagedPane): void { + armPaneFitContinuationRetry(pane, { + retry: () => { + pruneStaleSafeFitContinuations(pane) + if (!pendingSafeFitContinuations.get(pane)?.size) { + return true + } + return safeFit(pane) + }, + onExhausted: () => { + // Why: a reveal transaction must degrade after its bounded layout wait; + // leaving completion pending forever blocks deferred output release. + failPendingSafeFitContinuations(pane) + } + }) +} + export function cancelPendingSafeFitContinuations(pane: ManagedPane): void { + clearPaneFitContinuationRetry(pane) const operations = pendingSafeFitContinuations.get(pane) if (!operations) { return @@ -213,7 +259,7 @@ export function safeFitAndThen( pane: ManagedPane, operationKey: string, continuation: () => void, - options: { shouldContinue?: () => boolean } = {} + options: { shouldContinue?: () => boolean; retryIfUnmeasurable?: boolean } = {} ): SafeFitContinuationHandle { const operations = pendingSafeFitContinuations.get(pane) ?? new Map() const replaced = operations.get(operationKey) @@ -245,13 +291,17 @@ export function safeFitAndThen( `safe-fit-and-then:${operationKey}`, () => { if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) { - safeFit(pane) + if (!safeFit(pane) && options.retryIfUnmeasurable) { + armSafeFitContinuationRetry(pane) + } } } ) ) { return { completion, cancel } } - safeFit(pane) + if (!safeFit(pane) && options.retryIfUnmeasurable) { + armSafeFitContinuationRetry(pane) + } return { completion, cancel } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts new file mode 100644 index 00000000000..cb62cd15fb7 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import { writeForegroundTerminalChunk } from './pane-terminal-foreground-render-settle' + +type RefreshFn = (start: number, end: number, sync?: boolean) => void + +type RenderServiceStub = { + _isPaused: boolean + _needsFullRefresh: boolean + refreshRows: ReturnType> +} + +function createTerminal(paused: boolean): { + terminal: { + rows: number + buffer: { active: { cursorY: number; baseY: number; viewportY: number } } + _core: { refresh: ReturnType>; _renderService: RenderServiceStub } + refresh: ReturnType void>> + write: (data: string, callback?: () => void) => void + } + renderService: RenderServiceStub +} { + const renderService: RenderServiceStub = { + _isPaused: paused, + _needsFullRefresh: paused, + refreshRows: vi.fn() + } + const terminal = { + rows: 24, + buffer: { active: { cursorY: 0, baseY: 0, viewportY: 0 } }, + _core: { refresh: vi.fn(), _renderService: renderService }, + refresh: vi.fn<(start: number, end: number) => void>(), + write: (_data: string, callback?: () => void) => callback?.() + } + return { terminal, renderService } +} + +describe('writeForegroundTerminalChunk render-pause ownership', () => { + it('drives a paused render only for a currently visible reveal replay', () => { + const { terminal, renderService } = createTerminal(true) + + writeForegroundTerminalChunk(terminal, 'replayed snapshot bytes', { + forceViewportRefresh: true, + shouldReleaseRenderPause: () => true + }) + + expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + }) + + it('leaves a newly-hidden terminal paused when replay parsing finishes', () => { + const { terminal, renderService } = createTerminal(true) + let parsed: (() => void) | undefined + let visible = true + terminal.write = (_data: string, callback?: () => void) => { + parsed = callback + } + + writeForegroundTerminalChunk(terminal, 'late replay bytes', { + forceViewportRefresh: true, + shouldReleaseRenderPause: () => visible + }) + visible = false + parsed?.() + + expect(renderService.refreshRows).not.toHaveBeenCalled() + expect(renderService._isPaused).toBe(true) + expect(renderService._needsFullRefresh).toBe(true) + }) + + it('does not inspect RenderService on the ordinary forced-refresh path', () => { + const { terminal, renderService } = createTerminal(true) + const renderServiceRead = vi.fn(() => renderService) + Object.defineProperty(terminal._core, '_renderService', { get: renderServiceRead }) + + writeForegroundTerminalChunk(terminal, 'ordinary output', { + forceViewportRefresh: true + }) + + expect(renderServiceRead).not.toHaveBeenCalled() + expect(terminal._core.refresh).toHaveBeenCalledWith(0, 23, true) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 4819212adb1..508fb7e1240 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -1,3 +1,4 @@ +import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' export type ForegroundTerminalOutputTarget = { @@ -20,6 +21,7 @@ type ForegroundTerminalWriteOptions = { forceViewportRefresh?: boolean followupViewportRefresh?: boolean shouldRefreshViewportSynchronously?: () => boolean + shouldReleaseRenderPause?: () => boolean onParsed?: () => void onWriteFailure?: () => void } @@ -36,15 +38,21 @@ type ViewportSnapshot = { function refreshVisibleRows( terminal: ForegroundTerminalOutputTarget, - synchronously: boolean + synchronously: boolean, + shouldReleaseRenderPause?: () => boolean ): void { if (typeof terminal.rows !== 'number' || terminal.rows < 1) { return } - const start = 0 - const end = Math.max(0, terminal.rows - 1) try { + // Why: only reveal-owned replay may override xterm's paused observer state; + // ordinary or newly-hidden output must leave background rendering paused. + if (shouldReleaseRenderPause?.() === true && forceRepaintThroughRenderPause(terminal)) { + return + } + const start = 0 + const end = Math.max(0, terminal.rows - 1) // Why: DOM-rendered Windows ConPTY rewrites need an immediate repair, while // WebGL can merge this full-grid request into xterm's already-queued frame. if (synchronously && typeof terminal._core?.refresh === 'function') { @@ -100,13 +108,14 @@ function cancelScheduledViewportSettleRefresh(terminal: ForegroundTerminalOutput function scheduleViewportSettleRefresh( terminal: ForegroundTerminalOutputTarget, - shouldRefreshSynchronously?: () => boolean + shouldRefreshSynchronously?: () => boolean, + shouldReleaseRenderPause?: () => boolean ): void { cancelScheduledViewportSettleRefresh(terminal) if (typeof requestAnimationFrame === 'function') { const id = requestAnimationFrame(() => { pendingViewportSettleRefreshByTerminal.delete(terminal) - refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true) + refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true, shouldReleaseRenderPause) }) pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'raf', id }) return @@ -114,7 +123,7 @@ function scheduleViewportSettleRefresh( const id = setTimeout(() => { pendingViewportSettleRefreshByTerminal.delete(terminal) - refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true) + refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true, shouldReleaseRenderPause) }, 16) pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'timeout', id }) } @@ -124,7 +133,11 @@ function settleForegroundRender( beforeWriteViewport: ViewportSnapshot, options: ForegroundTerminalWriteOptions ): void { - refreshVisibleRows(terminal, options.shouldRefreshViewportSynchronously?.() ?? true) + refreshVisibleRows( + terminal, + options.shouldRefreshViewportSynchronously?.() ?? true, + options.shouldReleaseRenderPause + ) // Why: when output advances the viewport, Chromium can paint the freshly // scrolled top row one frame later than xterm finishes parsing. Repaint once // more after the scroll settles so the user doesn't need to jiggle the window. @@ -132,7 +145,11 @@ function settleForegroundRender( options.followupViewportRefresh || viewportChangedDuringWrite(terminal, beforeWriteViewport) ) { - scheduleViewportSettleRefresh(terminal, options.shouldRefreshViewportSynchronously) + scheduleViewportSettleRefresh( + terminal, + options.shouldRefreshViewportSynchronously, + options.shouldReleaseRenderPause + ) } } diff --git a/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs b/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs new file mode 100644 index 00000000000..78d3596f613 --- /dev/null +++ b/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs @@ -0,0 +1,139 @@ +// Codex-like INLINE-mode TUI (normal buffer, never alt-screen): history lines +// scroll into terminal scrollback while a live block (working spinner + input +// box + status line) repaints glued to the bottom of the screen, wrapped in +// synchronized-output brackets. This is the write shape a real Codex CLI +// produces mid-generation — the shape the alt-screen fixtures cannot cover. +// +// argv[2] = heartbeat file path (latest frame number, rewritten every tick). +// argv[3] = history lines per second (default 4) — raise it so a hidden/parked +// window accumulates a field-sized backlog for the reveal to race. +// The stream NEVER stops on its own; tests park/hide/reveal around it and +// assert the revealed terminal converges to the live frame without a resize. +const fs = require('node:fs') + +const heartbeatPath = process.argv[2] +const TICK_MS = 60 +const HISTORY_LINES_PER_SECOND = Math.max(0, Number(process.argv[3]) || 4) +const BLOCK_ROWS = 6 +// argv[4]: seed scrollback size — a field Codex session carries thousands of +// lines, which is what makes the reveal replay long enough to lose races. +const INITIAL_HISTORY_LINES = Math.max(0, Number(process.argv[4]) || 120) + +let frame = 0 +let hist = 0 + +function rows() { + return process.stdout.rows || 24 +} + +function cols() { + return process.stdout.columns || 80 +} + +function historyLine() { + hist += 1 + return `HIST_${String(hist).padStart(6, '0')} tool call output ${'-'.repeat(24)}` +} + +function liveBlock() { + const width = Math.max(20, Math.min(cols() - 2, 76)) + const bar = '─'.repeat(width) + const pad = (text) => `${`│ ${text}`.padEnd(width + 1, ' ')}│` + const top = Math.max(1, rows() - BLOCK_ROWS + 1) + const lines = [ + `╭${bar}╮`, + pad(`CODEX_FRAME_${String(frame).padStart(6, '0')} working${'.'.repeat(frame % 4).padEnd(3)}`), + pad(`tokens ${frame * 17} · ${frame % 2 === 0 ? 'thinking' : 'streaming'}`), + `╰${bar}╯`, + '› INPUT_BOX_READY_MARKER', + 'status: streaming · esc to interrupt' + ] + // Absolute-position to the block top and clear below, like ratatui's inline + // viewport redraw. + return `\x1b[${top};1H\x1b[J${lines.join('\r\n')}` +} + +// ratatui insert_before-style history: scroll one line into scrollback from +// the bottom row, then write the new history line just above the live block. +function insertHistory(count) { + const r = rows() + const histTop = Math.max(1, r - BLOCK_ROWS) + let out = '' + for (let i = 0; i < count; i += 1) { + out += `\x1b[${r};1H\n\x1b[${histTop};1H${historyLine()}` + } + return out +} + +let historyCarry = 0 + +function tick() { + frame += 1 + historyCarry += (HISTORY_LINES_PER_SECOND * TICK_MS) / 1000 + const historyThisTick = Math.floor(historyCarry) + historyCarry -= historyThisTick + let out = '\x1b[?2026h\x1b[?25l' + if (historyThisTick > 0) { + out += insertHistory(historyThisTick) + } + out += liveBlock() + out += '\x1b[?25h\x1b[?2026l' + process.stdout.write(out) + if (heartbeatPath) { + try { + fs.writeFileSync(heartbeatPath, String(frame)) + } catch { + // heartbeat is best-effort; the stream itself is the product + } + } +} + +// Codex-shaped startup: terminal queries (answered by xterm or the daemon's +// model responder) and mouse reporting, so the run takes the live-agent +// classification branches instead of the plain-shell ones. +process.stdout.write('\x1b[c\x1b[6n\x1b]10;?\x07\x1b]11;?\x07') +process.stdout.write('\x1b[?1002h\x1b[?1006h') + +// Seed scrollback before any park so the reveal replays real history. +{ + const seed = [] + for (let i = 0; i < INITIAL_HISTORY_LINES; i += 1) { + seed.push(historyLine()) + } + process.stdout.write(`${seed.join('\r\n')}\r\n`) +} + +const tickTimer = setInterval(tick, TICK_MS) +// A real inline TUI fully repaints its live block on SIGWINCH. Keep that +// behavior for realism, but tests must converge WITHOUT relying on it. +process.stdout.on('resize', () => { + process.stdout.write(`\x1b[?2026h\x1b[?25l${liveBlock()}\x1b[?25h\x1b[?2026l`) +}) +// Swallow query replies / mouse reports / keys like a real TUI agent. +process.stdin.resume() +if (process.stdin.isTTY) { + process.stdin.setRawMode(true) +} + +let stopping = false +function stop() { + if (stopping) { + return + } + stopping = true + clearInterval(tickTimer) + if (process.stdin.isTTY) { + process.stdin.setRawMode(false) + } + // Why: the e2e sends Ctrl+C while raw mode is active, so Node receives a + // byte instead of SIGINT; explicitly restore modes and terminate the fixture. + process.stdout.write('\x1b[?1002l\x1b[?1006l\x1b[?25h\x1b[?2026l', () => process.exit(0)) +} + +process.stdin.on('data', (data) => { + if (Buffer.from(data).includes(3)) { + stop() + } +}) +process.on('SIGINT', stop) +process.on('SIGTERM', stop) diff --git a/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts b/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts new file mode 100644 index 00000000000..f0ba620eb2c --- /dev/null +++ b/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts @@ -0,0 +1,718 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { PNG } from 'pngjs' +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + switchToOtherWorktree, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' +import { waitForTabParked } from './helpers/terminal-hidden-parking' + +// Field bug (v1.4.144-rc.4): switching back to a workspace whose Codex TUI kept +// streaming while hidden shows a mostly-blank terminal — live block (input box) +// missing, viewport stranded mid-buffer — until a manual resize (Cmd+L) forces +// SIGWINCH and Codex repaints. The alt-screen park/reveal specs never caught it +// because Codex runs in INLINE mode and keeps writing across the reveal. +// +// These tests drive a codex-shaped inline TUI that never stops writing, hide +// the pane across the gate/park boundaries, reveal, and require convergence to +// the live frame WITHOUT any resize: +// 1. viewport anchored at the buffer bottom (not stranded mid-scrollback), +// 2. a recent CODEX_FRAME + the input-box row visible in the on-screen rows, +// 3. still following (frame number advances on screen) after convergence, +// 4. xterm grid == fit proposal == PTY-applied size (no stale 80x24 PTY). + +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'codex-inline-live-block-fixture.cjs') +const FRAME_RE = /CODEX_FRAME_(\d+)/g +const INPUT_BOX_MARKER = 'INPUT_BOX_READY_MARKER' +// The fixture ticks every 60ms; allow a generous parse/delivery lag while +// still rejecting a frozen frame from before the hide. +const MAX_VISIBLE_FRAME_LAG = 50 + +type RevealProbe = { + ptyId: string | null + viewportY: number + baseY: number + cols: number + rows: number + proposed: { cols: number; rows: number } | null + appliedPtySize: { cols: number; rows: number } | null + screenRows: string[] +} + +function latestFrame(text: string): number { + let latest = -1 + for (const match of text.matchAll(FRAME_RE)) { + latest = Math.max(latest, Number(match[1])) + } + return latest +} + +function heartbeatFrame(heartbeatPath: string): number { + try { + return Number(readFileSync(heartbeatPath, 'utf8').trim()) + } catch { + return -1 + } +} + +// Why the pane resolves by tab (not a captured ptyId): agent quick-launch +// startup can respawn the tab's PTY after the initial bind, so a ptyId +// captured at mount can go stale while the pane itself stays healthy. +async function probeRevealedPane(page: Page, tabId: string): Promise { + return page.evaluate( + async ({ tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + const ptyId = pane.container.dataset.ptyId ?? null + const terminal = pane.terminal + const buffer = terminal.buffer.active + const screenRows: string[] = [] + for (let i = 0; i < terminal.rows; i += 1) { + const line = buffer.getLine(buffer.viewportY + i) + screenRows.push(line ? line.translateToString(true) : '') + } + let proposed: { cols: number; rows: number } | null = null + try { + proposed = pane.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + let appliedPtySize: { cols: number; rows: number } | null = null + try { + appliedPtySize = ptyId ? ((await window.api.pty.getSize(ptyId)) ?? null) : null + } catch { + appliedPtySize = null + } + return { + ptyId, + viewportY: buffer.viewportY, + baseY: buffer.baseY, + cols: terminal.cols, + rows: terminal.rows, + proposed, + appliedPtySize, + screenRows + } + }, + { tabId } + ) +} + +// Painted-pixels check: buffer-level assertions cannot see paint-layer bugs +// (atlas wipe races, paused-RenderService swallowed refreshes), where xterm's +// buffer is perfect but the canvas shows blank/stale cells until a resize. +// Measure the "ink" (non-background pixel ratio) of a horizontal band of the +// pane screenshot; the fixture's live block guarantees box-drawing + text ink +// in its bottom rows whenever paint is healthy. +function measureBandInkRatio( + screenshot: Buffer, + bandTopFraction: number, + bandBottomFraction: number +): number { + const png = PNG.sync.read(screenshot) + const colorCounts = new Map() + for (let offset = 0; offset < png.data.length; offset += 32) { + const key = + ((png.data[offset] ?? 0) << 16) | + ((png.data[offset + 1] ?? 0) << 8) | + (png.data[offset + 2] ?? 0) + colorCounts.set(key, (colorCounts.get(key) ?? 0) + 1) + } + let backgroundKey = 0 + let backgroundCount = -1 + for (const [key, count] of colorCounts) { + if (count > backgroundCount) { + backgroundKey = key + backgroundCount = count + } + } + const backgroundRed = (backgroundKey >> 16) & 0xff + const backgroundGreen = (backgroundKey >> 8) & 0xff + const backgroundBlue = backgroundKey & 0xff + const yStart = Math.max(0, Math.floor(png.height * bandTopFraction)) + const yEnd = Math.min(png.height, Math.ceil(png.height * bandBottomFraction)) + let ink = 0 + let total = 0 + for (let y = yStart; y < yEnd; y += 1) { + for (let x = 0; x < png.width; x += 1) { + const offset = (y * png.width + x) * 4 + const diff = + Math.abs((png.data[offset] ?? 0) - backgroundRed) + + Math.abs((png.data[offset + 1] ?? 0) - backgroundGreen) + + Math.abs((png.data[offset + 2] ?? 0) - backgroundBlue) + total += 1 + if (diff > 48) { + ink += 1 + } + } + } + return total > 0 ? ink / total : 0 +} + +async function forceWebglOnActiveTab(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { + ...state.settings, + terminalGpuAcceleration: 'on' + } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on') + }) +} + +async function paneClipRect( + page: Page, + tabId: string +): Promise<{ x: number; y: number; width: number; height: number } | null> { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + const rect = pane.container.getBoundingClientRect() + if (rect.width < 10 || rect.height < 10) { + return null + } + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }, tabId) +} + +async function isTerminalPaneMounted(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return Boolean(manager && manager.getPanes().length > 0) + }, tabId) +} + +function describeProbe(probe: RevealProbe | null): string { + if (!probe) { + return 'pane not mounted' + } + return JSON.stringify( + { + viewportY: probe.viewportY, + baseY: probe.baseY, + cols: probe.cols, + rows: probe.rows, + proposed: probe.proposed, + appliedPtySize: probe.appliedPtySize, + screenTail: probe.screenRows.slice(-10) + }, + null, + 1 + ) +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +type StreamingTabSetup = { + worktreeId: string + tabId: string + ptyId: string + heartbeatPath: string + stop: () => Promise +} + +// Why a fresh tab per test: the app instance is shared across this file's +// serial tests, so reusing the initial tab would type the launch command into +// the previous test's still-running fixture instead of a shell prompt. +// +// Why agent-marked: a real Codex tab carries launchAgent/telemetry, which +// flips the reveal into the live-agent reattach branches (mode-preserving +// resets, hidden startup query grammar, post-replay focus-in) — the branches +// the field bug lives behind. +async function startStreamingInlineTui( + page: Page, + testInfo: TestInfo, + options: { historyLinesPerSecond?: number; seedLines?: number } = {} +): Promise { + await waitForSessionReady(page) + const worktreeId = await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + const heartbeatPath = testInfo.outputPath(`codex-inline-heartbeat-${Date.now()}.txt`) + const command = `node ${JSON.stringify(FIXTURE_PATH)} ${JSON.stringify(heartbeatPath)} ${options.historyLinesPerSecond ?? 4} ${options.seedLines ?? 120}` + const tabId = await page.evaluate( + ({ worktreeId, command }) => { + const store = window.__store + if (!store) { + throw new Error('startStreamingInlineTui: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { launchAgent: 'codex' }) + state.queueTabStartupCommand(tab.id, { + command, + launchAgent: 'codex', + telemetry: { + agent_kind: 'codex', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, command } + ) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'agent-marked streaming tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + // WebGL on: the paint-layer seams under test (atlas wipes, render-pause + // release) only exist on the GPU renderer path. + await forceWebglOnActiveTab(page) + await expect + .poll( + async () => latestFrame((await probeRevealedPane(page, tabId))?.screenRows.join('\n') ?? ''), + { + timeout: 20_000, + message: 'inline TUI fixture did not start streaming in the visible pane' + } + ) + .toBeGreaterThan(5) + // Capture the PTY id only after the fixture streams: agent quick-launch can + // respawn the tab's PTY when the startup command binds. + const ptyId = (await probeRevealedPane(page, tabId))?.ptyId + if (!ptyId) { + throw new Error('streaming tab did not bind a PTY') + } + return { + worktreeId, + tabId, + ptyId, + heartbeatPath, + stop: async () => { + // Ctrl+C so the shared app does not accumulate streaming fixtures. + await sendToTerminal(page, ptyId, '\x03').catch(() => {}) + await page.waitForTimeout(100) + } + } +} + +async function assertRevealConvergence( + page: Page, + testInfo: TestInfo, + setup: StreamingTabSetup, + label: string +): Promise { + const { tabId, heartbeatPath } = setup + + // Premise: the fixture kept streaming while hidden. + const heartbeatAtReveal = heartbeatFrame(heartbeatPath) + expect(heartbeatAtReveal, 'fixture stopped streaming while hidden').toBeGreaterThan(5) + + let lastProbe: RevealProbe | null = null + await expect + .poll( + async () => { + lastProbe = await probeRevealedPane(page, tabId) + if (!lastProbe) { + return 'pane-not-mounted' + } + if (lastProbe.viewportY !== lastProbe.baseY) { + return `viewport-stranded viewportY=${lastProbe.viewportY} baseY=${lastProbe.baseY}` + } + const screen = lastProbe.screenRows.join('\n') + if (!screen.includes(INPUT_BOX_MARKER)) { + return 'input-box-row-missing' + } + const visibleFrame = latestFrame(screen) + const liveFrame = heartbeatFrame(heartbeatPath) + if (visibleFrame < 0 || liveFrame - visibleFrame > MAX_VISIBLE_FRAME_LAG) { + return `stale-frame visible=${visibleFrame} live=${liveFrame}` + } + return 'converged' + }, + { + timeout: 20_000, + message: `${label}: revealed pane did not converge to the live inline TUI without a resize` + } + ) + .toBe('converged') + .catch(async (error) => { + testInfo.annotations.push({ + type: `${label}-divergence-probe`, + description: describeProbe(lastProbe) + }) + const screenshotPath = testInfo.outputPath(`${label}-divergence.png`) + await page.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach(`${label}-divergence.png`, { + path: screenshotPath, + contentType: 'image/png' + }) + throw error + }) + + // Still following: the on-screen frame must keep advancing after convergence + // with the viewport still pinned to the bottom. + const convergedFrame = latestFrame( + (await probeRevealedPane(page, tabId))?.screenRows.join('\n') ?? '' + ) + await expect + .poll( + async () => { + const probe = await probeRevealedPane(page, tabId) + if (!probe || probe.viewportY !== probe.baseY) { + return -1 + } + return latestFrame(probe.screenRows.join('\n')) + }, + { + timeout: 10_000, + message: `${label}: revealed pane stopped following live output after convergence` + } + ) + .toBeGreaterThan(convergedFrame) + + // Geometry: no stale-80x24 leg — xterm grid, fit proposal, and PTY-applied + // size must agree without any manual resize. + const probe = await probeRevealedPane(page, tabId) + expect(probe, `${label}: pane disappeared after convergence`).not.toBeNull() + expect(probe!.proposed, `${label}: fit proposal diverges: ${describeProbe(probe)}`).toEqual({ + cols: probe!.cols, + rows: probe!.rows + }) + expect( + probe!.appliedPtySize, + `${label}: PTY applied-size read unavailable: ${describeProbe(probe)}` + ).not.toBeNull() + expect( + probe!.appliedPtySize, + `${label}: PTY applied size diverges: ${describeProbe(probe)}` + ).toEqual({ cols: probe!.cols, rows: probe!.rows }) + + // Painted pixels: the live block guarantees box-drawing + text ink in the + // pane's bottom rows. Blank band + healthy buffer = paint-layer divergence + // (atlas wipe race / paused RenderService) — the class a resize also heals. + const clip = await paneClipRect(page, tabId) + expect(clip, `${label}: pane rect unavailable for paint check`).not.toBeNull() + const bandTop = Math.max(0, 1 - 8 / (probe!.rows || 24)) + await expect + .poll( + async () => { + const shot = await page.screenshot({ clip: clip! }) + return measureBandInkRatio(shot, bandTop, 1) + }, + { + timeout: 10_000, + message: `${label}: live block rows painted blank while the buffer holds content (paint-layer divergence)` + } + ) + .toBeGreaterThan(0.005) +} + +async function resizeAppWindow( + electronApp: ElectronApplication, + deltaWidth: number, + deltaHeight: number +): Promise { + // Why the retry: the main-process utility context Playwright evaluates in + // can be transiently recycled ("Execution context was destroyed") right + // after heavy renderer work like a worktree switch; the resize itself is + // idempotent-safe to attempt again. + let lastError: unknown = null + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await electronApp.evaluate( + ({ BrowserWindow }, { deltaWidth, deltaHeight }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('No Electron window') + } + const [width, height] = window.getSize() + window.setSize(width + deltaWidth, height + deltaHeight) + }, + { deltaWidth, deltaHeight } + ) + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + throw lastError +} + +// CPU throttling around the reveal action simulates the loaded-machine +// conditions the field failures occur under, deterministically. +async function withCpuThrottle(page: Page, rate: number, run: () => Promise): Promise { + const session = await page.context().newCDPSession(page) + try { + await session.send('Emulation.setCPUThrottlingRate', { rate }) + return await run() + } finally { + await session.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => {}) + await session.detach().catch(() => {}) + } +} + +async function streamWhileParked(setup: StreamingTabSetup, minFrames: number): Promise { + const heartbeatBefore = heartbeatFrame(setup.heartbeatPath) + await expect + .poll(() => heartbeatFrame(setup.heartbeatPath), { + timeout: 60_000, + message: 'fixture did not keep streaming while hidden/parked' + }) + .toBeGreaterThan(heartbeatBefore + minFrames) +} + +test.describe('Inline TUI reveal convergence', () => { + test('hidden-but-mounted tab reveal converges while the inline TUI streams', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(120_000) + const setup = await startStreamingInlineTui(orcaPage, testInfo) + try { + // Tab B hides tab A. Reveal quickly — inside the cold-park delay — so + // the reveal exercises the hidden-delivery-gate restore, not parking. + const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId) + expect(tabBId).not.toBe(setup.tabId) + await orcaPage.waitForTimeout(Math.max(50, Math.min(PARKING_DELAY_MS / 2, 200))) + expect( + await isTerminalPaneMounted(orcaPage, setup.tabId), + 'hidden-mounted scenario cold-parked before reveal' + ).toBe(true) + + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await assertRevealConvergence(orcaPage, testInfo, setup, 'hidden-mounted-reveal') + } finally { + await setup.stop() + } + }) + + test('worktree switch reveal converges after a hidden-time window resize', async ({ + orcaPage, + electronApp + }, testInfo) => { + test.setTimeout(120_000) + const setup = await startStreamingInlineTui(orcaPage, testInfo, { + historyLinesPerSecond: 20 + }) + try { + // Surface hide: switch to ANOTHER WORKTREE (the field action), which + // suspends rendering and takes the heavy resume path on return. + const otherWorktreeId = await switchToOtherWorktree(orcaPage, setup.worktreeId) + test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-hide') + + // Change the window size while the pane is display:none (0x0 container, + // no fit runs). This is what Cmd+L's sidebar toggle does to every hidden + // workspace: at reveal the pane grid differs from the daemon snapshot's. + await resizeAppWindow(electronApp, -180, -120) + await orcaPage.waitForTimeout(2_500) + + await switchToWorktree(orcaPage, setup.worktreeId) + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await assertRevealConvergence(orcaPage, testInfo, setup, 'worktree-resize-reveal') + } finally { + await resizeAppWindow(electronApp, 180, 120).catch(() => {}) + await setup.stop() + } + }) + + test('parked tab reveal converges across repeated cycles while the inline TUI streams heavily', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(480_000) + const setup = await startStreamingInlineTui(orcaPage, testInfo, { + historyLinesPerSecond: 30, + seedLines: 8_000 + }) + try { + // Tab B hides tab A; the decoy then hides tab B so B (most recently + // hidden) takes the #8262 last-active exemption and tab A cold-parks. + const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId) + const decoyTabId = await createActiveTerminalTab(orcaPage, setup.worktreeId) + + // The field failure is periodic, not every reveal — cycle the park → + // stream → reveal boundary and require convergence every time. + const CYCLES = 6 + for (let cycle = 0; cycle < CYCLES; cycle += 1) { + if (cycle > 0) { + await activateTerminalTab(orcaPage, tabBId) + await activateTerminalTab(orcaPage, decoyTabId) + } + await waitForTabParked(orcaPage, setup.tabId, { parkDelayMs: PARKING_DELAY_MS }) + + // Accumulate a field-sized backlog against the parked (unmounted) + // view so the reveal replay races the live stream, like a real Codex. + await streamWhileParked(setup, 100) + + // Reveal under CPU throttle: a long replay parse + throttled frames is + // the loaded-machine window where the corrective fit and follow-anchor + // lose their races in the field. + await withCpuThrottle(orcaPage, 6, async () => { + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(3_000) + }) + const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealed.panes[0]?.ptyId).toBe(setup.ptyId) + await assertRevealConvergence(orcaPage, testInfo, setup, `parked-heavy-reveal-c${cycle}`) + } + } finally { + await setup.stop() + } + }) + + test('rapid tab hide/reveal flapping never wedges delivery for the streaming inline TUI', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(150_000) + const setup = await startStreamingInlineTui(orcaPage, testInfo, { + historyLinesPerSecond: 10 + }) + try { + const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId) + // Rapid flapping drives the hidden-delivery gate claim/release IPC and + // the hidden-output restore against each other at varied phases — the + // desync class behind "bytes dropped on a visible pane" field freezes. + for (let flap = 0; flap < 12; flap += 1) { + await activateTerminalTab(orcaPage, tabBId) + await orcaPage.waitForTimeout(50 + (flap % 3) * 120) + await activateTerminalTab(orcaPage, setup.tabId) + await orcaPage.waitForTimeout(50 + ((flap * 7) % 5) * 90) + } + await waitForActiveTerminalManager(orcaPage, 30_000) + await assertRevealConvergence(orcaPage, testInfo, setup, 'tab-flapping-reveal') + } finally { + await setup.stop() + } + }) + + test('rapid worktree switch flapping never wedges delivery for the streaming inline TUI', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(150_000) + const setup = await startStreamingInlineTui(orcaPage, testInfo, { + historyLinesPerSecond: 10, + seedLines: 4_000 + }) + try { + const otherWorktreeId = await switchToOtherWorktree(orcaPage, setup.worktreeId) + test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-flap') + // Surface-level flapping (the field action): suspend/resume rendering + + // heavy resume path race the gate resync and reveal repaint each cycle, + // under CPU throttle to widen the race windows like a loaded machine. + await withCpuThrottle(orcaPage, 6, async () => { + for (let flap = 0; flap < 10; flap += 1) { + await switchToWorktree(orcaPage, otherWorktreeId!) + await orcaPage.waitForTimeout(60 + (flap % 4) * 110) + await switchToWorktree(orcaPage, setup.worktreeId) + await orcaPage.waitForTimeout(60 + ((flap * 5) % 4) * 130) + } + }) + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await assertRevealConvergence(orcaPage, testInfo, setup, 'worktree-flapping-reveal') + } finally { + await setup.stop() + } + }) + + test('parked tab reveal converges after a parked-time window resize', async ({ + orcaPage, + electronApp + }, testInfo) => { + test.setTimeout(180_000) + const setup = await startStreamingInlineTui(orcaPage, testInfo, { + historyLinesPerSecond: 20 + }) + try { + await createActiveTerminalTab(orcaPage, setup.worktreeId) + await createActiveTerminalTab(orcaPage, setup.worktreeId) + await waitForTabParked(orcaPage, setup.tabId, { parkDelayMs: PARKING_DELAY_MS }) + + // Resize while parked: the remount measures a grid that matches neither + // the pre-park xterm nor the daemon snapshot — maximum dimension churn. + await resizeAppWindow(electronApp, -180, -120) + await streamWhileParked(setup, 100) + + await activateTerminalTab(orcaPage, setup.tabId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealed.panes[0]?.ptyId).toBe(setup.ptyId) + await assertRevealConvergence(orcaPage, testInfo, setup, 'parked-resize-reveal') + } finally { + await resizeAppWindow(electronApp, 180, 120).catch(() => {}) + await setup.stop() + } + }) +}) From c0f0810dd9c89c57e2130e5d7b5adcbef883845c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:25:52 -0700 Subject: [PATCH 03/35] Fix native Windows PTY startup query handling (#9500) * Fix native Windows PTY startup query handling * Fix daemon boot smoke protocol lookup * Fix Windows daemon repro protocol lookup --- config/scripts/daemon-boot-smoke.mjs | 5 +- .../windows-daemon-workspace-close-repro.mjs | 5 +- ...onpty-startup-query-and-focus-authority.md | 523 ++++++++++++++++++ src/main/daemon/daemon-protocol-version.ts | 12 + src/main/daemon/daemon-pty-adapter.test.ts | 66 +++ src/main/daemon/daemon-pty-adapter.ts | 34 +- src/main/daemon/daemon-pty-router.ts | 14 +- src/main/daemon/daemon-server.ts | 16 +- .../daemon/daemon-stream-data-batcher.test.ts | 17 + src/main/daemon/daemon-stream-data-batcher.ts | 49 +- src/main/daemon/daemon-stream-data-entry.ts | 46 ++ src/main/daemon/daemon-stream-data-split.ts | 42 +- src/main/daemon/daemon-stream-events.ts | 9 +- .../daemon/daemon-stream-keep-tail-drop.ts | 2 + .../daemon/degraded-daemon-pty-provider.ts | 15 +- src/main/daemon/session.test.ts | 56 ++ src/main/daemon/session.ts | 56 +- .../daemon/terminal-host-create-contract.ts | 7 +- src/main/daemon/terminal-host-options.ts | 27 + src/main/daemon/terminal-host.ts | 37 +- src/main/daemon/terminal-modes.ts | 11 + src/main/daemon/terminal-snapshot.ts | 20 + src/main/daemon/types.ts | 76 +-- src/main/ipc/pty.test.ts | 107 +++- src/main/ipc/pty.ts | 169 +++--- src/main/ipc/ssh.test.ts | 17 +- .../terminal-startup-color-query-replies.ts | 141 +---- src/main/providers/local-pty-provider.test.ts | 41 ++ src/main/providers/local-pty-provider.ts | 65 ++- src/main/providers/pty-provider-events.ts | 22 + src/main/providers/ssh-pty-provider.test.ts | 57 ++ src/main/providers/ssh-pty-provider.ts | 34 +- src/main/providers/types.ts | 30 +- src/main/runtime/orca-runtime.ts | 23 +- src/main/runtime/rpc/methods/terminal.ts | 49 +- .../ssh-relay-session-data-delivery.test.ts | 112 ++++ .../ssh-relay-session-terminal-error.test.ts | 3 +- src/main/ssh/ssh-relay-session.test.ts | 10 +- src/main/ssh/ssh-relay-session.ts | 27 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 2 + src/relay/pty-handler.test.ts | 67 +++ src/relay/pty-handler.ts | 134 ++++- .../terminal-pane/pty-connection.ts | 1 + .../terminal-pane/pty-dispatcher.ts | 6 + ...runtime-terminal-frame-drop-resync.test.ts | 47 +- .../remote-runtime-terminal-multiplexer.ts | 46 +- src/shared/pty-startup-ingress-intent.ts | 52 ++ src/shared/pty-startup-ingress.test.ts | 158 ++++++ src/shared/pty-startup-ingress.ts | 340 ++++++++++++ src/shared/terminal-stream-protocol.ts | 6 +- 51 files changed, 2415 insertions(+), 497 deletions(-) create mode 100644 docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md create mode 100644 src/main/daemon/daemon-protocol-version.ts create mode 100644 src/main/daemon/daemon-stream-data-entry.ts create mode 100644 src/main/daemon/terminal-host-options.ts create mode 100644 src/main/daemon/terminal-modes.ts create mode 100644 src/main/daemon/terminal-snapshot.ts create mode 100644 src/main/providers/pty-provider-events.ts create mode 100644 src/main/ssh/ssh-relay-session-data-delivery.test.ts create mode 100644 src/shared/pty-startup-ingress-intent.ts create mode 100644 src/shared/pty-startup-ingress.test.ts create mode 100644 src/shared/pty-startup-ingress.ts diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs index dc593b650b2..4ed155e8c5f 100644 --- a/config/scripts/daemon-boot-smoke.mjs +++ b/config/scripts/daemon-boot-smoke.mjs @@ -36,10 +36,11 @@ function log(message) { // Why: the daemon rejects a hello whose protocol version differs, so read the // current version from source rather than hardcoding a number that can drift. function readProtocolVersion() { - const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8') + const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts' + const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8') const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/) if (!match) { - throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts') + throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`) } return Number(match[1]) } diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs index 974e865722d..1385ffc17d2 100644 --- a/config/scripts/windows-daemon-workspace-close-repro.mjs +++ b/config/scripts/windows-daemon-workspace-close-repro.mjs @@ -22,7 +22,10 @@ function log(message) { } function readProtocolVersion() { - const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8') + const source = readFileSync( + join(projectDir, 'src/main/daemon/daemon-protocol-version.ts'), + 'utf8' + ) const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/) if (!match) { throw new Error('Could not read the daemon protocol version') diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md new file mode 100644 index 00000000000..1a0c9922a35 --- /dev/null +++ b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md @@ -0,0 +1,523 @@ +# Windows ConPTY Startup Query and Focus Authority Design + +Date: 2026-07-19 + +Status: Final decision; implementation pending + +## Problem + +On native Windows ConPTY sessions, a new agent can sometimes show terminal protocol bytes as user +text: + +```text +]10;rgb:2e2e/3434/3434\]11;rgb:ffff/ffff/ffff\ +``` + +An independent symptom can prefix user input with `[I`, the printable tail of the standard terminal +focus-in report `CSI I`. + +The OSC text is an Orca-generated reply to the agent's OSC 10/11 foreground/background query. The +agent can issue that query before a daemon-backed `spawn()` resolves to the renderer, and waits only +about 100 ms for the answer. Orca therefore has a short-lived main-side startup responder in +addition to the normal renderer/model query authorities. On affected ConPTY timing, the reply sent +to the PTY is returned as cooked output with its ESC bytes removed. The current ingestion order +records that cooked echo in the authoritative runtime model before any renderer-bound filtering. + +The focus symptom must not be treated as the same bug without evidence. ConPTY deliberately emits +DECSET 1004 (focus reporting) and DECSET 9001 (Win32 input mode) at startup. A direct native +PowerShell ConPTY capture consistently produced the bootstrap pair, while an injected `CSI I` was +consumed as a focus event and did not render as `[I`. The bootstrap is valid transport protocol; +the failure requires an additional agent, timing, input-mode, or replay condition. + +## Root Cause + +The startup OSC responder currently runs in Electron main only after runtime ingestion +([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public +data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while +daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out +data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A +renderer-only filter can therefore hide the symptom without removing it from the authoritative +model, daemon history, snapshots, or remote delivery. + +The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer +downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met +that evidentiary bar, so this design fixes the proven OSC path and adds the real focus-path harness +without pre-approving a speculative focus workaround. + +## Data Flow + +```text +node-pty / remote relay PTY + -> shell-ready marker scan + -> source-owned serialized ingress transaction + -> consume an early OSC query and write one canonical reply + -> match or losslessly release the measured native-Windows echo projection + -> authoritative emulator + persistence/history + -> runtime side effects and mobile/remote stream + -> renderer delivery or hidden-drop decision + -> live terminal / snapshot restore +``` + +Raw sequence spans travel beside cleaned strings through every downstream hop. Empty transformed +spans advance sequence and flow-control state without writing bytes into an emulator or view. + +## Rejected Prototype + +The current working-tree prototype is not the implementation of this design: + +- It removes ConPTY's leading `?1004h` in the renderer. This changes valid native-console focus + semantics for every Windows PTY, including programs unrelated to agents. +- It filters OSC echo text only after runtime/model ingestion. The live pane can look clean while a + hidden restore, reconnect, mobile view, or CLI snapshot still contains the garbage. +- Its echo state activates only after both color slots are answered. An echo of the first response + can escape if it arrives before the second query. +- Its partial-match buffer can be lost on timeout, and its substring search can remove a later + legitimate string that happens to equal a reply. + +Implementation starts by removing the prototype's bootstrap output filter and renderer-only cooked +echo filtering. Existing unrelated Windows focus-idle safeguards remain. + +## Existing Contracts Preserved + +This design extends, rather than replaces: + +- [`terminal-model-view-contract.md`](../terminal-model-view-contract.md), especially singular query + authority, authoritative model restore, and raw sequence ordering; +- [`terminal-query-authority.md`](../terminal-query-authority.md), especially delivered-versus- + hidden-dropped ownership and replay silence; +- [`terminal-side-effect-authority.md`](../terminal-side-effect-authority.md), especially parsing PTY + bytes once in main before renderer delivery. + +The startup responder is a bounded exception needed before normal delivered/dropped ownership can be +established. It must join the same ingestion decision, not operate as an unrelated renderer scrub. + +## Decision 1: Source-Owned PTY Ingress Transaction + +Sanitization must run on the host that owns the PTY, before that host mutates any authoritative +model or persistence. Electron main is too late for daemon sessions: `Session` has already advanced +its sequence, written its emulator, recorded pending output, and broadcast the data before main's +provider callback runs. + +The transaction therefore has three installations of the same shared state machine: + +- in `LocalPtyProvider`, before its configured runtime callback and data listeners; +- in daemon `Session`, before `outputSequence`, emulator writes, pending/checkpoint records, and + attached-client fanout; +- in relay `PtyHandler`, before relay replay/history buffering and `pty.data` fanout. Main's + `SshPtyProvider` is only an RPC proxy and never sanitizes relay output. + +Electron main receives already-classified daemon data. A remote-runtime desktop client does not +reinterpret the stream; the remote Orca host owns its transaction. WSL sessions follow their actual +provider owner but never enable the native-Windows compatibility projection. + +Fresh-session creation carries startup-transaction intent atomically. Daemon `createOrAttach` +receives the recognized-agent intent, execution-host kind, deadline, and validated renderer-pushed +color attributes. It first decides fresh versus reattach; only a fresh result constructs the +transaction before releasing the subprocess's already-buffered early output. A reattach discards +the intent without arming state. Cancellation, spawn failure, and teardown clear intent before any +PTY id can be reused. + +Local creation installs the transaction before subscribing to node-pty output. Relay `pty.spawn` +carries the same fresh-session intent and installs it before releasing relay PTY output; relay +`pty.attach` never accepts it. If any owner cannot establish this ordering, the early responder is +not armed and normal query authority handles the query. + +Increment `DAEMON_PROTOCOL_VERSION` for the new create/wire shape and add a named numeric-threshold +predicate in the daemon adapter/router. Current exact-version hello behavior remains; supported +legacy versions route through their existing adapters and fail the predicate. The SSH relay gets a +separate versioned spawn/data capability because daemon protocol support says nothing about relay +support. + +An old daemon or relay keeps the legacy behavior and is never treated as having sanitized snapshots +or history. Main must not apply a second best-effort scrub to legacy output. New sessions receive +the invariant only after the owning daemon/relay is upgraded or restarted; an already-attached +legacy session remains explicitly outside it. + +### Composition with shell-ready preprocessing + +The ingress sequence domain begins after the existing shell-ready scanner. That scanner may hold +transport bytes and removes its private ready marker; marker bytes have never belonged to terminal +model or delivery sequence space and continue not to count. Its released non-marker bytes enter the +new ingress transaction in original order. + +Snapshot and teardown barriers drain in pipeline order: the shell-ready scanner first releases its +non-marker buffer, then the ingress transaction resolves or abandons its candidate, then the model, +persistence, and views observe the resulting emissions. No later stage may introduce an unmetered +string transform. + +### Raw sequence and emission contract + +The state machine accepts source chunks with an explicit raw half-open range and returns zero or +more ordered emissions: + +```ts +type PtyIngressSourceChunk = { + data: string + rawStartSeq: number + rawEndSeq: number +} + +type PtyIngressEmission = { + data: string + rawStartSeq: number + rawEndSeq: number + transformed: boolean +} +``` + +The raw range is the post-shell-ready ingress sequence domain and remains contiguous even when +`data` is shorter after sanitization. The ordered emissions partition accepted source ranges without +overlap. Producer flow-control acknowledgement follows those emitted raw spans and is counted once +from `rawEndSeq - rawStartSeq`, never from emitted string length. A held prefix delays its ACK; the +buffer is strictly bounded and the serialized queue cannot acknowledge later output ahead of it. + +The ingress raw high-water advances when a source chunk is accepted. Model-applied and +view-delivered high-waters advance only through ordered emissions, including empty transformed +emissions that consume a raw range. Runtime/mobile listener metadata carries +`rawLength = rawEndSeq - rawStartSeq`, not `data.length`. A restore that overlaps a transformed span +cannot slice cleaned text by raw offset and must request a fresh authoritative snapshot, matching +the existing `rawLength !== data.length` safety rule. + +This metadata is end-to-end, not local to the state machine. Implementation changes the complete +path: + +1. daemon `Session` or relay `PtyHandler` emission callback; +2. daemon/relay batching, coalescing, splitting, and wire notification; +3. daemon adapter or `SshPtyProvider` decode; +4. `OrcaRuntimeService` model and runtime/mobile listeners; +5. main renderer batching, pending/drop accounting, and preload payload; +6. renderer reconciliation and remote-runtime binary/live frame decoding. + +The wire representation carries `data`, `seq = rawEndSeq`, `rawLength`, and `transformed`. A +span-only emission has empty `data` but non-zero `rawLength`; no layer may drop it before advancing +its high-water and producer ACK. It is not written to an emulator or xterm. Coalescing is permitted +only for contiguous spans and sums raw lengths independently of string lengths. A transformed +emission is indivisible because there is no byte-for-byte raw-to-clean offset; splitters must flush +it as its own frame or request snapshot reconciliation instead of slicing it. + +If a snapshot is requested while a partial candidate is held, the transaction first abandons that +candidate and releases its bytes unchanged as an ordered emission. The authoritative emulator and +snapshot sequence therefore describe the same raw high-water. + +### Serialization and teardown + +Each PTY has a non-reentrant serialized ingress queue. A provider write may synchronously produce a +nested callback, but that callback is appended after the current source chunk rather than delivered +ahead of its remaining bytes. Timeout releases and snapshot barriers enter the same queue. + +On exit, the queue releases all buffered bytes, applies those emissions to the authoritative model +and persistence, and fans them out before `onPtyExit`, `pty:exit`, or PTY state cleanup. Relay +disposal must flush both ingress prefixes and its existing pending output batches before clearing +them or killing PTYs, matching the natural-exit flush-before-`pty.exit` order. No drain may recreate +state after teardown. Ordinary chunks pass through as one unchanged emission. + +## Decision 2: Startup OSC Queries Are Consumed Authoritatively + +The early OSC 10/11 responder remains because removing it would regress daemon-hosted agent startup +and the agent's short color-query timeout. Its implementation moves into the source-owned ingress +transaction. + +When registered for an agent spawn, it: + +1. recognizes exact OSC 10/11 query grammar across provider chunks; +2. builds replies from validated renderer-pushed foreground/background attributes; +3. emits the canonical ST-terminated, 16-bit-channel reply used by renderer/model query authority, + regardless of whether the query ended with BEL or ST; +4. records each reply transaction before writing it to the provider; +5. consumes the answered query from the authoritative model and view emissions so neither the + hidden model nor a delivered renderer can answer it a second time; +6. begins echo recognition as soon as each individual reply is written. + +If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged +to normal query authority. A reattach never registers startup response state, matching current +behavior. + +### Exact authority transfer + +Startup query interception opens only for a fresh session whose atomic creation installed the +transaction before buffered output release. It closes at the first of: + +- both OSC 10 and OSC 11 slots have been answered; +- the startup deadline expires; +- main sends an ordered authority-close control after either the consuming-view handshake or the + hidden-runtime ownership mark is established; +- the spawn fails, is cancelled, reattaches, or exits. + +Closing query interception does not discard already-written reply candidates. Echo recognition has +its own bounded lifetime and may finish or drain after normal authority takes over. A close and a +provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to +a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control +method, and the source owner acknowledges the applied ingress sequence. Queries before that ordered +boundary are either consumed at source or removed from emissions; queries after it pass unchanged +to the normal delivered/hidden decision. Each query therefore belongs to exactly one authority. + +The regular authority rules continue after the bounded startup window: + +- delivered live bytes are answered by the live view; +- hidden-dropped live bytes are answered by the runtime model; +- replayed, seeded, and snapshot bytes are answered by nobody; +- the daemon's persistence emulator never writes replies. + +Implementation must amend `terminal-model-view-contract.md` and `terminal-query-authority.md` to +name this source-owner startup authority, its opening/closing events, and its no-replay rule. It is a +real third responder class, not an undocumented exception. + +## Decision 3: Matched Echo Suppression Is Lossless and Pre-Model + +Only native Windows ConPTY agent spawns that pass the deterministic provider harness enable a +compatibility projection for replies written by the startup transaction. WSL and POSIX SSH PTYs do +not. A remote-runtime PTY can enable it only on its owning Windows host under the same evidence and +capability gate. + +The harness records the exact projection ConPTY returns for the canonical ST reply, including its +chunking and any console transformation. The implementation must not assume that the projection is +always merely "remove ESC", and BEL or other reply forms are not added without separate evidence. +For every written reply, the transaction records that exact expected projection. Recognition is: + +- FIFO in reply-write order; +- anchored at the next possible output position, not an unbounded substring search; +- active immediately for each reply instead of waiting for both color slots; +- bounded by the startup deadline and maximum reply length; +- streaming across chunk boundaries. + +If incoming bytes diverge from the expected projection, all buffered bytes are released unchanged +and that candidate is abandoned. If the deadline expires or the PTY is cleared while a prefix is +buffered, the prefix is released through the serialized ingestion queue; it is never discarded. A +match advances the raw span but emits no bytes to the model, persistence, or views. + +Projection matching cannot prove provenance. An application can print the same projected text at +the candidate position, causing a false-positive removal while the later real echo remains visible. +This is the central drawback of the workaround. It is accepted only if the real provider harness +shows a stable, immediate projection inside the narrow registered-agent startup window; otherwise +Orca disables the projection and keeps the visible output rather than risking deletion. + +When the projection is enabled, the exact-collision behavior is explicit: the first identical +anchored candidate is removed and a later real echo is allowed through. The test fixture must assert +that result. This accepts a narrowly bounded false-positive risk instead of pretending provenance +is knowable; the release gate must document the observed timing window and justify that tradeoff. +Unknown, delayed, or interleaved transformations pass through visibly. + +## Decision 4: Preserve ConPTY Focus Protocol + +Orca must deliver the ConPTY bootstrap `?1004h`/`?9001h` to live terminal emulators unchanged. It +must not remove, reorder, or fabricate transport bootstrap modes. + +The `[I` investigation gets a deterministic harness before a behavior change. The harness must +exercise the actual renderer focus callback and provider write path, not only inject `CSI I` +directly into node-pty. It records: + +- raw provider output and its order; +- whether the focus report came from live ConPTY bootstrap state, an application-owned DECSET 1004, + or replayed snapshot state; +- the exact bytes written to the provider; +- the exact bytes returned by the provider and stored in the model; +- agent lifecycle state when the report was emitted. + +The implementation gate is strict: + +- If stale snapshot modes cause the report, fix snapshot rehydration. Transport bootstrap focus mode + is not persisted as application ownership. +- If a live agent receives transport focus before it owns terminal focus reporting, record that + evidence without shipping a suppression heuristic from this document. +- If provider input/output transformation corrupts a correctly owned focus report, fix or sanitize + that transformation at the same pre-model ingress boundary used for OSC replies. + +No global `?1004h` filter ships under any outcome. + +If the harness proves a focus-ownership race, a follow-up design is required before implementation. +That design must define the output-to-input ownership signal, distinguish xterm focus events from +identical typed/pasted/programmatic bytes, specify startup transitions and deadlines, define any +snapshot/wire metadata, and cover the separate explicit reattach-focus write path. This document +does not pre-approve an ownership state machine whose input provenance cannot yet be represented. + +## Snapshot and Replay Rules + +Interactive modes in a snapshot are capabilities of the live application, not proof that a new +view should emit input immediately. + +- Cold restore into a fresh shell keeps the existing full mode reset. +- Reattach to a live agent may rehydrate application-owned focus mode, but never transport-only + bootstrap ownership. +- Snapshot serialization/replay must not mutate live ownership trackers. +- A snapshot containing an OSC query or an old Orca reply never produces a provider write. +- Model and renderer snapshots must both be free of matched cooked reply projections. + +Any future focus ownership metadata must be explicit; it cannot be inferred from serialized +`?1004h` text. + +## Failure Policy + +Safety is asymmetric: + +- This design does not intentionally suppress a focus notification; evidence of a focus-ownership + race triggers a follow-up design instead. +- Passing through an unrecognized OSC echo is safer than deleting output that may belong to the + application. +- Missing a startup color reply falls back to the existing renderer/model authority. Duplicate + replies are forbidden. +- Losing buffered output on timeout or teardown is forbidden. + +## Cross-Platform and Remote Scope + +- Native Windows local and daemon ConPTY: startup response plus evidence-gated echo compatibility + path; focus investigation only until a proven root cause has its own complete design. +- WSL: normal Linux terminal semantics; no ConPTY echo or focus workaround. +- SSH: the same ingress/query ownership ordering, but no native Windows echo projection unless the + remote host protocol later supplies explicit equivalent evidence. +- Remote runtime: the remote Orca host owns ingestion and must implement the same contract there; + desktop local main does not reinterpret its stream. +- Mobile/web views: consume the sanitized authoritative model stream and retain exactly-one query + response authority through the existing terminal-driver election. + +## Edge Cases + +- A query or echo split at any byte boundary, including OSC ST split across chunks. +- The first reply echo arriving before the second color query. +- Unrelated output, an exact application-text collision, or a partial match before the real echo. +- Timeout, snapshot, detach, process exit, daemon shutdown, or relay disposal while bytes are held. +- A provider write causing a synchronous nested output callback. +- Fresh spawn versus reattach, cancellation, PTY-id reuse, and an authority-close control racing + with output. +- BEL-terminated queries still receiving the canonical ST reply. +- Empty transformed spans crossing coalescing, splitting, ACK, mobile, and remote-runtime layers. +- Old daemon/relay protocol versions and sessions that survive an application upgrade. +- WSL or POSIX SSH running from a Windows desktop without inheriting local ConPTY workarounds. +- Focus gained through normal terminal input versus the separate explicit reattach-focus write path. + +## Test Plan + +### Deterministic provider harness + +- Capture the native ConPTY bootstrap across natural and forced chunk boundaries. +- Reproduce the OSC reply echo with the real provider and an agent query fixture. +- Exercise BEL and ST queries and assert the same canonical ST reply, plus separate OSC 10/11 + queries and combined OSC 10 `?;?`. +- Force the first reply echo before the second query. +- Split every byte boundary in query and echo fixtures. +- Print the expected projection immediately before the real echo and assert that the first + identical candidate is removed while the later echo passes through. +- Exercise focus gain/loss through the actual xterm focus path. + +### Ingress integration + +- Assert local runtime ingestion and daemon emulator/pending/checkpoint persistence receive + classified data before storing it. +- Assert renderer and mobile delivery receive the same visible output. +- Assert raw start/end spans advance by original provider length, producer ACK contribution is + counted exactly once, and mobile/runtime metadata reports the raw span rather than string length. +- Assert a partial candidate is released unchanged on mismatch, timeout, snapshot barrier, move, + and teardown, including a prefix held from an earlier callback. +- Assert re-entrant provider callbacks remain ordered behind the source chunk that caused the + write. +- Assert restore overlap across a transformed or delayed span requests a fresh snapshot rather + than slicing cleaned text. +- Assert `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` install the source-side seam; + assert `SshPtyProvider` does not reinterpret relay data. +- Assert WSL, SSH, and remote-runtime streams do not enable the native-Windows projection. +- Assert a capable daemon sanitizes live output, snapshots, pending records, checkpoints, and cold + restore; assert capable relay replay/history has the same property. Assert old daemon and relay + versions are detected and never represented as sanitized. + +### Authority and restore + +- Assert an early-consumed query is answered once by its source owner and never by renderer or + hidden model. +- Assert normal delivered/dropped query authority resumes after startup state clears. +- Assert the main snapshot, renderer snapshot, hidden reveal, reconnect, and mobile subscription do + not contain cooked OSC text. +- Assert replay never sends OSC or focus replies. +- Assert ordinary native console focus behavior remains enabled. +- Assert the focus harness captures both terminal `onData` and explicit reattach-focus writes. Any + later ownership implementation defines its tests in the required follow-up design. + +### End-to-end acceptance + +On native Windows, repeatedly create, hide, reveal, and reconnect new and resumed agent sessions. +Before typing, neither live output nor any restore path may contain `]10;rgb`/`]11;rgb`. Repeat the +focus/blur scenarios to classify `[I`; if it reproduces through an ownership race, this work stops +at the follow-up-design gate rather than claiming the symptom fixed. Application-owned focus +behavior must still move the TUI caret correctly after focus and reattach. Repeat with a plain +PowerShell terminal and a native focus-event consumer to prove no global regression. + +Run renderer and main PTY suites, runtime snapshot/query suites, node/web typechecks, lint, +formatting, max-lines ratchet, reliability gates, and Electron validation. SSH ingestion changes also +require the repository's SSH end-to-end procedure. + +## UI Quality Bar + +This is not a layout, styling, or copy change. Existing terminal rendering and focus behavior must +look unchanged except that matched protocol garbage is absent. A passing terminal screenshot has a +clean prompt, no clipped or duplicated startup output, no restore flash, and the existing cursor and +focus presentation. + +## Review Screenshots + +1. A fresh native-Windows agent prompt after startup, with no OSC reply text. +2. The same session after hide/reveal restore, still clean and without duplicated output. +3. The session after focus, blur, and reconnect, showing the focus-harness outcome and prompt state. +4. A plain native PowerShell terminal after focus/blur, showing unchanged adjacent behavior. + +## Rollout + +1. **Correct seam and rollback.** Remove the prototype filters. Add the shared serialized ingress + state machine, explicit raw-span emissions, and separate ACK accounting after shell-ready + preprocessing. Install pass-through mode in `LocalPtyProvider`, daemon `Session`, and relay + `PtyHandler` before their models, persistence/replay, and fanout. +2. **Protocol and authority contract.** Bump the daemon protocol, add relay capability/version gates, + carry atomic fresh-spawn intent, define legacy fallback, and amend the canonical model/query + authority documents with exact transfer events. +3. **Startup transaction.** Move OSC startup recognition/reply into source ingress, retain canonical + ST replies, consume answered queries for model and views, and retain raw sequence accounting. +4. **Windows compatibility projection.** Gate the measured projection on provider evidence; add FIFO + anchored recognition, serialized re-entrant delivery, lossless drains, false-positive coverage, + and authoritative daemon/model/snapshot tests. +5. **Focus evidence.** Land the real focus-path harness. If it proves a focus-ownership race, stop + for the required follow-up design; a direct snapshot or provider-transformation bug may be fixed + only with a failing regression test that selects that branch. +6. **Electron and SSH gates.** Validate visible, hidden, restored, mobile-owned, native console, WSL, + and SSH scenarios before removing the old startup implementation. + +Each slice must keep query authority singular. The compatibility projection does not ship without a +model-snapshot assertion, and no focus behavior change ships before the deterministic focus harness +fails on the old behavior and passes on the new behavior. + +## Lightweight Eng Review + +- Scope: limited to the proven OSC startup corruption plus deterministic focus evidence. Global + focus filtering and an unproven ownership state machine remain out of scope. +- Architecture/data flow: classification belongs at each PTY source owner after shell-ready + preprocessing and before every authoritative model, persistence, replay, or delivery consumer. +- Failure modes covered: partial/mismatched projections, false-positive collision, nested writes, + authority races, snapshot and teardown drains, protocol-version skew, reattach, and host + isolation. +- Test coverage required: byte-boundary unit tests for the shared transaction; local, daemon, relay, + runtime/mobile, restore, and legacy-protocol integration tests; real native-Windows provider and + renderer-focus harnesses; Electron and SSH end-to-end validation. +- Performance/blast radius: ordinary output is a pass-through emission. Buffering is bounded by one + startup reply candidate and its deadline. Protocol and sequence metadata touch every delivery + path, so existing high-throughput, ACK, hidden-drop, and reconnect tests are mandatory. +- UI quality bar: terminal layout and styling are unchanged; only matched startup garbage + disappears, without cursor/focus regressions or restore flashes. +- Required review screenshots: the four terminal states in `Review Screenshots`. +- Residual risks: the native projection may be too unstable to enable; exact projected application + output can collide; `[I` may require a separately reviewed focus-ownership design. + +## Non-Goals + +- Replacing xterm's general query parser. +- Filtering arbitrary escape-looking terminal output. +- Disabling ConPTY focus or Win32 input mode globally. +- Changing WSL, SSH, or remote-runtime terminal semantics to imitate local Windows. +- Solving unrelated MCP startup warnings reported beside the terminal garbage. + +## Final Invariants + +1. Provider bytes are classified once before model ingestion and view delivery. +2. A live query has exactly one responder; replay has none. +3. Source-owner persistence, runtime/model state, and every view agree on removal of a matched + startup-reply projection. +4. Raw provider sequence accounting survives sanitization. +5. Buffered unmatched output is always released; the sanitizer cannot silently lose user data. +6. ConPTY bootstrap modes reach live emulators unchanged. +7. No focus suppression ships from this design; a proven ownership race requires a follow-up design. +8. PTY teardown clears all startup-transaction buffers and focus-harness measurement state. diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts new file mode 100644 index 00000000000..119a3bb14d7 --- /dev/null +++ b/src/main/daemon/daemon-protocol-version.ts @@ -0,0 +1,12 @@ +// Why: daemons survive app updates, so wire behavior must be version-gated. +export const PROTOCOL_VERSION = 24 +export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24 +export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 +export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 +] as const + +export function supportsPtyStartupIngress(protocolVersion: number): boolean { + return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 14243aa15eb..707d2800e15 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -144,6 +144,72 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' }) }) + it('carries classified startup spans from the daemon source to the adapter', async () => { + const onData = vi.fn() + adapter.onData(onData) + const { id } = await adapter.spawn({ + cols: 80, + rows: 24, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000, + ...(process.platform === 'win32' + ? { echoProjection: 'windows-conpty-esc-stripped' as const } + : {}) + } + }) + const query = '\x1b]10;?\x07' + lastSubprocess._simulateData(query) + lastSubprocess._simulateData('prompt') + + await waitFor(() => onData.mock.calls.length >= 2) + + expect(lastSubprocess.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\') + expect(onData).toHaveBeenCalledWith({ + id, + data: '', + sequenceChars: query.length, + seq: query.length, + transformed: true + }) + expect(onData).toHaveBeenCalledWith({ id, data: 'prompt' }) + await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({ + data: expect.not.stringContaining(']10;rgb') + }) + }) + + it('omits startup intent and close control for the preserved v23 protocol', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + pid: null, + shellState: 'unsupported', + snapshot: null + } as never) + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 }) + try { + await legacy.spawn({ + sessionId: 'legacy-session', + cols: 80, + rows: 24, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 + } + }) + const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1] + expect(createPayload).not.toHaveProperty('startupIngress') + await expect(legacy.closeStartupQueryAuthority('legacy-session')).resolves.toBe(0) + expect(requestSpy).not.toHaveBeenCalledWith('closeStartupQueryAuthority', expect.anything()) + } finally { + legacy.dispose() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + it('uses worktreeId as session prefix when provided', async () => { const result = await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' }) expect(result.id).toContain('wt-1') diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 0fe8ecc2a4d..81e91f16c63 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -14,6 +14,7 @@ import { CLEAN_DISCONNECT_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PROTOCOL_VERSION, + supportsPtyStartupIngress, type CreateOrAttachResult, type DaemonEvent, type GetSnapshotResult, @@ -105,6 +106,8 @@ export class DaemonPtyAdapter implements IPtyProvider { id: string data: string sequenceChars?: number + transformed?: boolean + seq?: number }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] @@ -142,6 +145,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // must never see them, so gating makes them silent no-ops there. private supportsProducerFlowControl: boolean private supportsAuthoritativeBufferSnapshots: boolean + private supportsStartupIngress: boolean private pausedProducerSessionIds = new Set() // Why tracked here: the daemon's background set (keep-tail stream thinning // + transient-fact scan authority) dies with the daemon process/socket; @@ -185,6 +189,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 this.supportsProducerFlowControl = this.protocolVersion >= 19 this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20 + this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) this.client.onDisconnected(() => { for (const id of this.pausedProducerSessionIds) { this.producerResumesOwedOnReconnect.add(id) @@ -299,7 +304,10 @@ export class DaemonPtyAdapter implements IPtyProvider { terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, shellReadySupported, ...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}), - ...(historySeed ? { historySeed } : {}) + ...(historySeed ? { historySeed } : {}), + ...(this.supportsStartupIngress && opts.startupIngress + ? { startupIngress: opts.startupIngress } + : {}) }) let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null @@ -925,7 +933,13 @@ export class DaemonPtyAdapter implements IPtyProvider { } onData( - callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + callback: (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + }) => void ): () => void { this.dataListeners.push(callback) return () => { @@ -1455,9 +1469,11 @@ export class DaemonPtyAdapter implements IPtyProvider { listener({ id: event.sessionId, data: event.payload.data, - ...(event.payload.sequenceChars === undefined + ...((event.payload.rawLength ?? event.payload.sequenceChars) === undefined ? {} - : { sequenceChars: event.payload.sequenceChars }) + : { sequenceChars: event.payload.rawLength ?? event.payload.sequenceChars }), + ...(event.payload.transformed ? { transformed: true } : {}), + ...(event.payload.seq === undefined ? {} : { seq: event.payload.seq }) }) } } else if (event.event === 'sessionBackgroundMarker') { @@ -1518,6 +1534,16 @@ export class DaemonPtyAdapter implements IPtyProvider { } }) } + + async closeStartupQueryAuthority(id: string): Promise { + if (!this.supportsStartupIngress) { + return 0 + } + const result = await this.client.request<{ appliedSeq: number }>('closeStartupQueryAuthority', { + sessionId: id + }) + return result.appliedSeq + } } // Why: ENOENT/ECONNREFUSED with syscall 'connect' mean the socket is diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index f8cad04b43b..a726b4000d9 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -17,6 +17,8 @@ export class DaemonPtyRouter implements IPtyProvider { id: string data: string sequenceChars?: number + transformed?: boolean + seq?: number }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] @@ -143,6 +145,10 @@ export class DaemonPtyRouter implements IPtyProvider { await this.adapterFor(id).clearBuffer(id) } + async closeStartupQueryAuthority(id: string): Promise { + return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0 + } + acknowledgeDataEvent(id: string, charCount: number): void { this.adapterFor(id).acknowledgeDataEvent(id, charCount) } @@ -183,7 +189,13 @@ export class DaemonPtyRouter implements IPtyProvider { } onData( - callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + callback: (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + }) => void ): () => void { this.dataListeners.push(callback) return () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 15ec9fecd21..93ee537c126 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -23,6 +23,8 @@ import type { SubprocessHandle } from './session' import { checkPtySpawnHealth } from './pty-subprocess' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' import { isTuiAgent } from '../../shared/tui-agent-config' +import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import { isNativeWindowsLocalPtySpawn } from '../runtime/terminal-model-query-authority' import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, @@ -707,11 +709,18 @@ export class DaemonServer { terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, historySeed: p.historySeed, + startupIngress: parsePtyStartupIngressIntent(p.startupIngress, { + allowWindowsEchoProjection: isNativeWindowsLocalPtySpawn({ + connectionId: null, + cwd: p.cwd, + shellOverride: p.shellOverride + }) + }), ...(p.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } : {}), streamClient: { - onData: (data) => { + onData: (data, rawLength = data.length, transformed = false, seq) => { // Scan BEFORE enqueue: the batcher may keep-tail drop this // chunk, but its facts must be captured regardless. this.transientFactRelay.onSessionData(p.sessionId, data) @@ -722,7 +731,10 @@ export class DaemonServer { performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS this.streamDataBatcher.enqueue(clientId, p.sessionId, data, { flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS + flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq }) }, onExit: (code) => { diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 32d85ff87b0..12eec59f758 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -77,6 +77,23 @@ describe('DaemonStreamDataBatcher', () => { } }) + it('preserves transformed span metadata through an immediate flush', () => { + const { batcher, streamSocket } = createBatcher() + + batcher.enqueue('client-1', 'session-1', '', { + flushImmediately: true, + rawLength: 9, + seq: 17, + transformed: true + }) + + expect(streamSocket.write).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({ + event: 'data', + payload: { data: '', rawLength: 9, seq: 17, sequenceChars: 9, transformed: true } + }) + }) + it('keeps large pending output batched even when an interactive redraw follows', () => { vi.useFakeTimers() try { diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 84f7465b913..b27c4020217 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -13,6 +13,7 @@ import { type PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' import type { DaemonEvent } from './types' +import { appendDaemonStreamData, type DaemonStreamEnqueueOptions } from './daemon-stream-data-entry' type StreamDataClient = { streamSocket: Socket | null @@ -59,11 +60,6 @@ const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024 // sessions × this ≈ tens of KB. const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024 -type EnqueueOptions = { - flushImmediately?: boolean - flushMaxChars?: number -} - type DaemonStreamDataBatcherOptions = { maxLineBytes?: number /** Fires after each stream-socket write — the only place backlog grows, so @@ -97,26 +93,19 @@ export class DaemonStreamDataBatcher { this.salvageDroppedData = options.salvageDroppedData ?? (() => '') } - enqueue(clientId: string, sessionId: string, data: string, options: EnqueueOptions = {}): void { + enqueue( + clientId: string, + sessionId: string, + data: string, + options: DaemonStreamEnqueueOptions = {} + ): void { const client = this.getClient(clientId) if (!client?.streamSocket || client.streamSocket.destroyed) { return } const batch = this.getOrCreateBatch(clientId) - const last = batch.queue.at(-1) - // Never coalesce across a control entry — it marks a position in the - // session's byte order. - if (last?.sessionId === sessionId && !last.control) { - last.data += data - } else { - batch.queue.push({ sessionId, data }) - } - batch.queuedChars += data.length - batch.queuedCharsBySession.set( - sessionId, - (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length - ) + appendDaemonStreamData(batch, sessionId, data, options) if (this.isSessionDroppable(sessionId)) { // Keep-tail scales down as more backgrounded sessions queue, bounding @@ -256,12 +245,16 @@ export class DaemonStreamDataBatcher { }) } const end = - entry.data.length <= BULK_WRITE_SLICE_CHARS + entry.transformed || entry.data.length <= BULK_WRITE_SLICE_CHARS ? entry.data.length : clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS) const slice = entry.data.slice(0, end) const entrySequenceChars = entry.sequenceChars ?? entry.data.length - const sliceSequenceChars = entrySequenceChars === 0 ? 0 : slice.length + const sliceSequenceChars = entry.transformed + ? entrySequenceChars + : entrySequenceChars === 0 + ? 0 + : slice.length if (end >= entry.data.length) { batch.queue.shift() } else { @@ -278,7 +271,15 @@ export class DaemonStreamDataBatcher { } else { batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter) } - writeStreamDataEvents(socket, entry.sessionId, slice, this.maxLineBytes, sliceSequenceChars) + writeStreamDataEvents( + socket, + entry.sessionId, + slice, + this.maxLineBytes, + sliceSequenceChars, + entry.seq, + entry.transformed + ) this.onAfterSocketWrite?.() } if (retained.length > 0) { @@ -369,7 +370,9 @@ export class DaemonStreamDataBatcher { entry.sessionId, entry.data, this.maxLineBytes, - entry.sequenceChars ?? entry.data.length + entry.sequenceChars ?? entry.data.length, + entry.seq, + entry.transformed ) this.onAfterSocketWrite?.() } diff --git a/src/main/daemon/daemon-stream-data-entry.ts b/src/main/daemon/daemon-stream-data-entry.ts new file mode 100644 index 00000000000..6d44c6a49e8 --- /dev/null +++ b/src/main/daemon/daemon-stream-data-entry.ts @@ -0,0 +1,46 @@ +import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' + +export type DaemonStreamEnqueueOptions = { + flushImmediately?: boolean + flushMaxChars?: number + rawLength?: number + transformed?: boolean + seq?: number +} + +export function appendDaemonStreamData( + batch: PendingStreamDataBatch, + sessionId: string, + data: string, + options: DaemonStreamEnqueueOptions +): void { + const last = batch.queue.at(-1) + // Why: control and transformed spans mark indivisible source-stream positions. + if ( + last?.sessionId === sessionId && + !last.control && + !last.transformed && + options.transformed !== true + ) { + last.data += data + const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length + const combinedRawLength = rawLengthBefore + (options.rawLength ?? data.length) + last.sequenceChars = combinedRawLength === last.data.length ? undefined : combinedRawLength + last.seq = options.seq + } else { + batch.queue.push({ + sessionId, + data, + ...(options.rawLength === undefined || options.rawLength === data.length + ? {} + : { sequenceChars: options.rawLength }), + ...(options.transformed ? { transformed: true } : {}), + ...(options.seq === undefined ? {} : { seq: options.seq }) + }) + } + batch.queuedChars += data.length + batch.queuedCharsBySession.set( + sessionId, + (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length + ) +} diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts index 0e15cf49523..986aeb7abca 100644 --- a/src/main/daemon/daemon-stream-data-split.ts +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -9,18 +9,26 @@ import type { Socket } from 'node:net' export function encodeStreamDataEvent( sessionId: string, data: string, - sequenceChars?: number + rawLength?: number, + seq?: number, + transformed?: boolean ): string { return encodeNdjson({ type: 'event', event: 'data', sessionId, - payload: { data, ...(sequenceChars === undefined ? {} : { sequenceChars }) } + payload: { + data, + ...(seq === undefined ? {} : { seq }), + ...(rawLength === undefined ? {} : { rawLength }), + ...(rawLength === undefined ? {} : { sequenceChars: rawLength }), + ...(transformed ? { transformed: true } : {}) + } }) } -function streamDataEventLineBytes(sessionId: string, data: string, sequenceChars?: number): number { - return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, sequenceChars), 'utf8') +function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8') } function isHighSurrogate(value: number): boolean { @@ -100,15 +108,27 @@ export function writeStreamDataEvents( sessionId: string, data: string, maxLineBytes: number, - sequenceChars = data.length + rawLength = data.length, + seq?: number, + transformed = false ): void { - const explicitSequenceChars = sequenceChars === data.length ? undefined : sequenceChars - for (const chunk of splitStreamDataForNdjson( + const explicitRawLength = rawLength === data.length ? undefined : rawLength + if (transformed) { + streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true)) + return + } + const carriesMetadata = explicitRawLength !== undefined || seq !== undefined + const chunks = splitStreamDataForNdjson( sessionId, data, - maxLineBytes, - explicitSequenceChars - )) { - streamSocket.write(encodeStreamDataEvent(sessionId, chunk, explicitSequenceChars)) + carriesMetadata ? Math.max(1, maxLineBytes - 96) : maxLineBytes, + explicitRawLength + ) + let consumed = 0 + for (const chunk of chunks) { + consumed += chunk.length + const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed) + const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined + streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq)) } } diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index 1644642bd09..b47e7413992 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -5,7 +5,14 @@ export type DataEvent = { type: 'event' event: 'data' sessionId: string - payload: { data: string; sequenceChars?: number } + payload: { + data: string + seq?: number + rawLength?: number + transformed?: boolean + /** Legacy v23 name retained for old adapter fixtures. */ + sequenceChars?: number + } } export type ExitEvent = { diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts index 0dc57e53522..0ef7bd0aab9 100644 --- a/src/main/daemon/daemon-stream-keep-tail-drop.ts +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -21,6 +21,8 @@ export type StreamQueueEntry = { /** Original PTY characters represented by data. Salvaged query copies are * delivered bytes but represent zero new positions in the source stream. */ sequenceChars?: number + seq?: number + transformed?: boolean control?: DaemonEvent } diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 572cb0aca37..591c2cfa53d 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -3,6 +3,7 @@ import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shu import type { IPtyProvider, PtyBackgroundStreamEvent, + PtyDataEvent, PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, @@ -26,11 +27,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { private fallback: ManagedPtyProvider private sessionProviders = new Map() private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: { - id: string - data: string - sequenceChars?: number - }) => void)[] = [] + private dataListeners: ((payload: PtyDataEvent) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] constructor(opts: { @@ -148,6 +145,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.providerFor(id).clearBuffer(id) } + async closeStartupQueryAuthority(id: string): Promise { + return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0 + } + acknowledgeDataEvent(id: string, charCount: number): void { this.providerFor(id).acknowledgeDataEvent(id, charCount) } @@ -187,9 +188,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return this.fallback.getProfiles() } - onData( - callback: (payload: { id: string; data: string; sequenceChars?: number }) => void - ): () => void { + onData(callback: (payload: PtyDataEvent) => void): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 3b8f068868c..b5446524ebf 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -106,6 +106,11 @@ describe('Session', () => { cols?: number rows?: number launchAgent?: TuiAgent + startupIngress?: { + colors: { foreground: string; background: string } + deadlineMs: number + echoProjection?: 'windows-conpty-esc-stripped' + } wslDistro?: string }): Session { session = new Session({ @@ -116,6 +121,7 @@ describe('Session', () => { wslDistro: opts?.wslDistro, subprocess, shellReadySupported: opts?.shellReadySupported ?? false, + ...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}), ...(opts?.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } : {}) @@ -187,6 +193,56 @@ describe('Session', () => { expect(received1).toEqual(['broadcast']) expect(received2).toEqual(['broadcast']) }) + + it('classifies startup queries and cooked echoes before model, persistence, and fanout', () => { + createSession({ + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000, + echoProjection: 'windows-conpty-esc-stripped' + } + }) + const onData = vi.fn() + session.attachClient({ onData, onExit: () => {} }) + const query = '\x1b]10;?\x07' + const echo = ']10;rgb:2e2e/3434/3434\\' + + subprocess.simulateData(query) + subprocess.simulateData(echo) + subprocess.simulateData('prompt') + + expect(subprocess.written).toEqual(['\x1b]10;rgb:2e2e/3434/3434\x1b\\']) + expect(onData.mock.calls).toEqual([ + ['', query.length, true, query.length], + ['', echo.length, true, query.length + echo.length], + ['prompt'] + ]) + expect(session.takePendingOutput(false)?.records).toEqual([ + { kind: 'output', data: 'prompt' } + ]) + expect(session.getSnapshot()).toMatchObject({ + outputSequence: query.length + echo.length + 'prompt'.length + }) + expect(session.getSnapshot()?.snapshotAnsi).toContain('prompt') + expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb') + }) + + it('releases a held cooked-echo prefix before taking a snapshot', () => { + createSession({ + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000, + echoProjection: 'windows-conpty-esc-stripped' + } + }) + subprocess.simulateData('\x1b]10;?\x07') + subprocess.simulateData(']10;rgb:2e2e/') + + const snapshot = session.getSnapshot() + + expect(snapshot?.snapshotAnsi).toContain(']10;rgb:2e2e/') + expect(snapshot?.outputSequence).toBe('\x1b]10;?\x07]10;rgb:2e2e/'.length) + }) }) describe('write', () => { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7ca52cf0c2a..1c414046cb3 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -12,6 +12,11 @@ import { isPowerShellProcess } from '../../shared/shell-process-detection' import { killWithDescendantSweep } from '../pty-descendant-termination' import type { TuiAgent } from '../../shared/types' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { + PtyStartupIngress, + type PtyIngressEmission, + type PtyStartupIngressIntent +} from '../../shared/pty-startup-ingress' import type { PendingOutputRecord, SessionState, @@ -97,11 +102,12 @@ export type SessionOptions = { // reaper, dead sessions (and their ~5000-row scrollback emulators) accumulate // for the lifetime of the long-lived daemon process. onExit?: (code: number) => void + startupIngress?: PtyStartupIngressIntent } type AttachedClient = { token: symbol - onData: (data: string) => void + onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void onExit: (code: number) => void } @@ -135,6 +141,7 @@ export class Session { private forceKillSent = false private subprocessDisposed = false private readonly physicalExit = new PhysicalExitTracker() + private readonly startupIngress: PtyStartupIngress constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -170,6 +177,11 @@ export class Session { } this.postReadyFlushGate = new PostReadyFlushGate(() => this.flushPreReadyQueue()) + this.startupIngress = new PtyStartupIngress({ + ...(opts.startupIngress ? { intent: opts.startupIngress } : {}), + write: (data) => this.subprocess.write(data), + onEmission: (emission) => this.emitSubprocessOutput(emission) + }) this.subprocess.onData((data) => this.handleSubprocessData(data)) this.subprocess.onExit((code) => this.handleSubprocessExit(code)) } @@ -389,7 +401,7 @@ export class Session { this.subprocess.signal(sig) } - attachClient(client: { onData: (data: string) => void; onExit: (code: number) => void }): symbol { + attachClient(client: Omit): symbol { const token = Symbol('attach') this.attachedClients.push({ token, ...client }) return token @@ -413,6 +425,7 @@ export class Session { } getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { + this.startupIngress.snapshotBarrier() if (this._disposed) { return null } @@ -518,7 +531,9 @@ export class Session { } prepareForFinalSnapshot(): string { - return this.releaseHeldShellReadyBytes() + const held = this.releaseHeldShellReadyBytes() + this.startupIngress.snapshotBarrier() + return held } dispose(): void { @@ -533,6 +548,8 @@ export class Session { // move this capture below #teardownSubprocess or the `_state = 'exited'` // assignment — #teardownSubprocess flips `_disposed` but the invariant // depends on the PRE-flip value of `_state`. + this.releaseHeldShellReadyBytes() + this.startupIngress.drainAndClose() const wasTerminating = this._isTerminating && this._state !== 'exited' const clientsToNotify = wasTerminating ? this.attachedClients.slice() : [] if (wasTerminating) { @@ -667,25 +684,29 @@ export class Session { this.postReadyFlushGate.notifyData() } - this.emitSubprocessOutput(data) + this.startupIngress.accept(data) } - private emitSubprocessOutput(data: string): void { - if (data.length === 0) { - return - } - + private emitSubprocessOutput(emission: PtyIngressEmission): void { + const { data } = emission + const rawLength = emission.rawEndSeq - emission.rawStartSeq // Why: daemon stream thinning can omit bytes before main sees them. The // absolute count lets an authoritative snapshot cover those gaps while // renderer reconciliation deduplicates any queued post-snapshot tail. - this.outputSequence += data.length + this.outputSequence += rawLength // Feed data to headless emulator for state tracking - this.emulator.write(data) - this.recordPendingOutput({ kind: 'output', data }) + if (data.length > 0) { + this.emulator.write(data) + this.recordPendingOutput({ kind: 'output', data }) + } // Broadcast to attached clients for (const client of this.attachedClients) { - client.onData(data) + if (emission.transformed || rawLength !== data.length) { + client.onData(data, rawLength, true, this.outputSequence) + } else { + client.onData(data) + } } } @@ -695,13 +716,14 @@ export class Session { return } + this.releaseHeldShellReadyBytes() + this.startupIngress.drainAndClose() this._exitCode = code this._state = 'exited' this._isTerminating = false // Why resume:false — the child is reaped, so there is nothing to unblock; // only the failsafe timer must not outlive the session. this.releaseProducerPause({ resume: false }) - this.releaseHeldShellReadyBytes() if (this.killTimer) { clearTimeout(this.killTimer) @@ -740,10 +762,14 @@ export class Session { // bytes can be stripped. If readiness never completes, preserve the // previous behavior by releasing any held prefix before timeout or exit // state changes discard it. - this.emitSubprocessOutput(heldBytes) + this.startupIngress.accept(heldBytes) return heldBytes } + closeStartupQueryAuthority(): number { + return this.startupIngress.closeQueryAuthority() + } + private transitionToReady(postMarkerBytesObserved = false): void { this._shellState = 'ready' this.shellReadyScanState = null diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 10cd5733cd3..47fba51b2c3 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -1,6 +1,7 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TuiAgent } from '../../shared/types' import type { ShellReadyState, TerminalSnapshot } from './types' +import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' export type CreateOrAttachOptions = { sessionId: string @@ -19,7 +20,11 @@ export type CreateOrAttachOptions = { shellReadySupported?: boolean shellReadyTimeoutMs?: number historySeed?: string - streamClient: { onData: (data: string) => void; onExit: (code: number) => void } + startupIngress?: PtyStartupIngressIntent + streamClient: { + onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void + onExit: (code: number) => void + } } export type CreateOrAttachResult = { diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts new file mode 100644 index 00000000000..a879a618c25 --- /dev/null +++ b/src/main/daemon/terminal-host-options.ts @@ -0,0 +1,27 @@ +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { SubprocessHandle } from './session' +import type { TakePendingOutputResult, TerminalSnapshot } from './types' + +export type TerminalHostOptions = { + spawnSubprocess: (opts: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + envToDelete?: string[] + command?: string + startupCommandDelivery?: StartupCommandDelivery + shellOverride?: string + terminalWindowsWslDistro?: string | null + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + }) => SubprocessHandle + // Why: graceful shutdown checkpoints must finish in-process before teardown. + onFinalCheckpoint?: ( + sessionId: string, + snapshot: TerminalSnapshot, + records: TakePendingOutputResult['records'] + ) => void + // Why: tests need deterministic tombstone eviction without thousands of sessions. + maxTombstones?: number +} diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index ba15c6ed814..d8458aa81ad 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,8 +1,7 @@ -import { Session, type SubprocessHandle } from './session' +import { Session } from './session' import { normalizePtySize } from './daemon-pty-size' import { shellPathSupportsPtyStartupBarrier } from './shell-ready' import { resolveProcessCwd } from '../providers/process-cwd' -import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { SessionNotFoundError, @@ -11,42 +10,17 @@ import { type TerminalSnapshot } from './types' import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' +import type { TerminalHostOptions } from './terminal-host-options' import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown' import { TerminalSessionTeardown } from './terminal-session-teardown' import { resolveWslSessionContext } from './wsl-session-context' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' +export type { TerminalHostOptions } from './terminal-host-options' const DEFAULT_MAX_TOMBSTONES = 1000 -export type TerminalHostOptions = { - spawnSubprocess: (opts: { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - envToDelete?: string[] - command?: string - startupCommandDelivery?: StartupCommandDelivery - shellOverride?: string - terminalWindowsWslDistro?: string | null - terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' - }) => SubprocessHandle - // Why: on graceful shutdown, the host writes final checkpoints for all live - // sessions before killing them. This bypasses the RPC round-trip — the daemon - // writes checkpoints in-process, guaranteeing completion before teardown. - onFinalCheckpoint?: ( - sessionId: string, - snapshot: TerminalSnapshot, - records: TakePendingOutputResult['records'] - ) => void - // Why: production keeps a large cap, but tests need a small deterministic cap - // without spawning thousands of full terminal sessions. - maxTombstones?: number -} - export class TerminalHost { private sessions = new Map() private sessionTeardown = new TerminalSessionTeardown(this.sessions) @@ -147,6 +121,7 @@ export class TerminalHost { subprocess, shellReadySupported, historySeed: opts.historySeed, + ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), wslDistro, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's @@ -197,6 +172,10 @@ export class TerminalHost { this.getAliveSession(sessionId).write(data) } + closeStartupQueryAuthority(sessionId: string): number { + return this.getAliveSession(sessionId).closeStartupQueryAuthority() + } + resize(sessionId: string, cols: number, rows: number): void { this.getAliveSession(sessionId).resize(cols, rows) } diff --git a/src/main/daemon/terminal-modes.ts b/src/main/daemon/terminal-modes.ts new file mode 100644 index 00000000000..41718b196fc --- /dev/null +++ b/src/main/daemon/terminal-modes.ts @@ -0,0 +1,11 @@ +export type TerminalModes = { + bracketedPaste: boolean + mouseTracking: boolean + mouseTrackingMode?: 'none' | 'x10' | 'vt200' | 'drag' | 'any' + sgrMouseMode?: boolean + sgrMousePixelsMode?: boolean + applicationCursor: boolean + alternateScreen: boolean + /** Kitty keyboard protocol flags used only to reseed a warm daemon emulator. */ + kittyKeyboardFlags?: number +} diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts new file mode 100644 index 00000000000..653d8fc4bd6 --- /dev/null +++ b/src/main/daemon/terminal-snapshot.ts @@ -0,0 +1,20 @@ +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalModes } from './terminal-modes' + +export type TerminalSnapshot = { + snapshotAnsi: string + /** Parser tail is already counted by the snapshot sequence and must restore last. */ + pendingEscapeTailAnsi?: string + /** Normal buffer captured separately while snapshotAnsi holds an alternate buffer. */ + scrollbackAnsi: string + oscLinks?: TerminalOscLinkRange[] + rehydrateSequences: string + cwd: string | null + modes: TerminalModes + cols: number + rows: number + scrollbackLines: number + lastTitle?: string + /** Optional because persisted snapshots and older v19 daemons lack it. */ + outputSequence?: number +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 0daaf0ac0cf..5c148dbf238 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,4 +1,3 @@ -import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { ConfirmForegroundProcessRequest, GetForegroundProcessRequest @@ -12,67 +11,24 @@ export type { // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TuiAgent } from '../../shared/types' -// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or -// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bump when adding daemon wire behavior so same-version old daemons do -// not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 24 -export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 -export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 -] as const +import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +export type { TerminalModes } from './terminal-modes' +import type { TerminalSnapshot } from './terminal-snapshot' +export type { TerminalSnapshot } from './terminal-snapshot' +export { + CLEAN_DISCONNECT_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + PREVIOUS_DAEMON_PROTOCOL_VERSIONS, + PROTOCOL_VERSION, + PTY_STARTUP_INGRESS_PROTOCOL_VERSION, + supportsPtyStartupIngress +} from './daemon-protocol-version' // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported' -// ─── Terminal Snapshot ────────────────────────────────────────────── -export type TerminalSnapshot = { - snapshotAnsi: string - /** Trailing incomplete escape sequence the emulator ingested but xterm's - * parser is still holding (a PTY read ended mid-escape). Restorers must - * write this LAST — after their own post-replay resets, immediately before - * post-snapshot live chunks — so the continuation bytes complete it - * exactly as live (Bug E / #7329, notes/garble-fuzz-divergences.md). Its - * bytes are already counted by the snapshot seq. */ - pendingEscapeTailAnsi?: string - /** Normal buffer captured separately while snapshotAnsi holds an active - * alternate buffer. Empty for normal-screen snapshots. */ - scrollbackAnsi: string - oscLinks?: TerminalOscLinkRange[] - rehydrateSequences: string - cwd: string | null - modes: TerminalModes - cols: number - rows: number - scrollbackLines: number - lastTitle?: string - /** Absolute UTF-16 character count ingested by this live daemon session. - * Optional because persisted snapshots and older v19 daemons lack it. */ - outputSequence?: number -} - -export type TerminalModes = { - bracketedPaste: boolean - mouseTracking: boolean - mouseTrackingMode?: 'none' | 'x10' | 'vt200' | 'drag' | 'any' - sgrMouseMode?: boolean - sgrMousePixelsMode?: boolean - applicationCursor: boolean - alternateScreen: boolean - /** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed - * parity ONLY. Consumed by the daemon warm-reattach path: the spawn - * result threads them into seedHeadlessTerminal, which re-applies them to - * the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags) - * so hidden `CSI ? u` answers the real flags instead of ?0u. - * rehydrateSequences must never push these into a renderer xterm — - * POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative - * (terminal-query-authority.md §kitty). */ - kittyKeyboardFlags?: number -} - // The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it // depends only on TerminalModes here) — re-exported so existing importers of // `./types` keep working. @@ -115,9 +71,16 @@ export type CreateOrAttachRequest = { shellReadyTimeoutMs?: number /** Recovered ANSI applied before the new subprocess can emit startup output. */ historySeed?: string + startupIngress?: PtyStartupIngressIntent } } +export type CloseStartupQueryAuthorityRequest = { + id: string + type: 'closeStartupQueryAuthority' + payload: { sessionId: string } +} + export type CancelCreateOrAttachRequest = { id: string type: 'cancelCreateOrAttach' @@ -337,6 +300,7 @@ export type DaemonRequest = | GetSnapshotRequest | GetSizeRequest | TakePendingOutputRequest + | CloseStartupQueryAuthorityRequest // ─── RPC Responses (Daemon → Client, on control socket) ──────────── diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c8843bd5076..5fddd31c1f8 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3852,7 +3852,8 @@ describe('registerPtyHandlers', () => { result.id, 'daemon output', expect.any(Number), - 'daemon output'.length + 'daemon output'.length, + undefined ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: result.id, @@ -8257,14 +8258,17 @@ describe('registerPtyHandlers', () => { mockProc.proc.write.mockClear() mainWindow.webContents.send.mockClear() - mockProc.emitData('\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready') + const sourceData = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready' + mockProc.emitData(sourceData) expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, - data: 'ready' + data: 'ready', + rawLength: sourceData.length, + transformed: true }) } finally { vi.useRealTimers() @@ -8291,14 +8295,17 @@ describe('registerPtyHandlers', () => { mockProc.proc.write.mockClear() mainWindow.webContents.send.mockClear() - mockProc.emitData('\x1b]10;?;?\x1b\\ready') + const sourceData = '\x1b]10;?;?\x1b\\ready' + mockProc.emitData(sourceData) expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, - data: 'ready' + data: 'ready', + rawLength: sourceData.length, + transformed: true }) } finally { vi.useRealTimers() @@ -8371,13 +8378,28 @@ describe('registerPtyHandlers', () => { } }) - it('answers daemon agent startup OSC color queries before spawn resolves', async () => { + it('accepts source-classified daemon startup spans before spawn resolves', async () => { vi.useFakeTimers() - let dataHandler: ((payload: { id: string; data: string }) => void) | null = null + type ProviderData = { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + } + let dataHandler: ((payload: ProviderData) => void) | null = null const write = vi.fn() - const spawn = vi.fn(async (options: { sessionId?: string }) => { + const query = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\' + const spawn = vi.fn(async (options: { sessionId?: string; startupIngress?: unknown }) => { const id = options.sessionId ?? 'daemon-pty' - dataHandler?.({ id, data: '\x1b]10;?\x1b\\\x1b]11;?\x1b\\daemon-ready' }) + dataHandler?.({ + id, + data: '', + sequenceChars: query.length, + transformed: true, + seq: query.length + }) + dataHandler?.({ id, data: 'daemon-ready' }) return { id } }) setLocalPtyProvider({ @@ -8395,7 +8417,7 @@ describe('registerPtyHandlers', () => { getForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), - onData: vi.fn((handler: (payload: { id: string; data: string }) => void) => { + onData: vi.fn((handler: (payload: ProviderData) => void) => { dataHandler = handler return () => {} }), @@ -8408,7 +8430,16 @@ describe('registerPtyHandlers', () => { } as never) try { - registerPtyHandlers(mainWindow as never) + let seq = 0 + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + onPtyData: vi.fn( + (_id: string, _data: string, _at: number, rawLength: number) => (seq += rawLength) + ), + registerPty: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, @@ -8420,22 +8451,39 @@ describe('registerPtyHandlers', () => { } })) as { id: string } - expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\') - expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + startupIngress: expect.objectContaining({ + colors: { foreground: '#eeeeee', background: '#111111' }, + deadlineMs: 5_000 + }) + }) + ) + expect(write).not.toHaveBeenCalled() vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, - data: 'daemon-ready' + data: 'daemon-ready', + seq: query.length + 'daemon-ready'.length, + rawLength: query.length + 'daemon-ready'.length, + transformed: true }) } finally { vi.useRealTimers() } }) - it('drops renderer sequence metadata when an answered OSC query is batched', async () => { + it('preserves source raw sequence metadata when a consumed query is batched', async () => { vi.useFakeTimers() + type ProviderData = { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + } const providerEvents: { - dataHandler?: (payload: { id: string; data: string }) => void + dataHandler?: (payload: ProviderData) => void } = {} const write = vi.fn() const spawn = vi.fn(async (options: { sessionId?: string }) => ({ @@ -8456,7 +8504,7 @@ describe('registerPtyHandlers', () => { getForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), - onData: vi.fn((handler: (payload: { id: string; data: string }) => void) => { + onData: vi.fn((handler: (payload: ProviderData) => void) => { providerEvents.dataHandler = handler return () => {} }), @@ -8471,8 +8519,8 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), createPreAllocatedTerminalHandle: vi.fn(() => null), - onPtyData: vi.fn((_id: string, data: string) => { - seq += data.length + onPtyData: vi.fn((_id: string, data: string, _at: number, rawLength = data.length) => { + seq += rawLength return seq }), registerPty: vi.fn() @@ -8493,17 +8541,24 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() providerEvents.dataHandler?.({ id: spawnResult.id, data: 'prefix' }) + const query = '\x1b]10;?\x1b\\\x1b]11;?\x1b\\' providerEvents.dataHandler?.({ id: spawnResult.id, - data: '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready' + data: '', + sequenceChars: query.length, + transformed: true, + seq: 'prefix'.length + query.length }) + providerEvents.dataHandler?.({ id: spawnResult.id, data: 'ready' }) vi.advanceTimersByTime(2) - expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\') - expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\') + expect(write).not.toHaveBeenCalled() expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, - data: 'prefixready' + data: 'prefixready', + seq: 'prefix'.length + query.length + 'ready'.length, + rawLength: 'prefix'.length + query.length + 'ready'.length, + transformed: true }) } finally { vi.useRealTimers() @@ -9925,7 +9980,8 @@ describe('registerPtyHandlers', () => { result.id, 'hidden output', expect.any(Number), - 'hidden output'.length + 'hidden output'.length, + undefined ) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) // Why out-of-band: an in-band empty pty:data chunk is ambiguous with @@ -10477,7 +10533,8 @@ describe('registerPtyHandlers', () => { 'daemon-session', 'pre-spawn prompt\x1b[c', expect.any(Number), - 'pre-spawn prompt\x1b[c'.length + 'pre-spawn prompt\x1b[c'.length, + undefined ) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 8c6b89d0d48..956383b7651 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -149,13 +149,7 @@ import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home- import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing' import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { - answerStartupTerminalColorQueries, - clearStartupTerminalColorQueryReplies, - getStartupTerminalColorQueryReplyColors, - moveStartupTerminalColorQueryReplies, - registerStartupTerminalColorQueryReplies -} from './terminal-startup-color-query-replies' +import { getStartupTerminalColorQueryReplyColors } from './terminal-startup-color-query-replies' import { assertFolderWorkspacePathUsable, getFolderWorkspacePathStatus @@ -459,20 +453,14 @@ function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined { } } -function getProviderForStartupTerminalColorReply(ptyId: string): IPtyProvider | undefined { - const ownedConnectionId = ptyOwnership.get(ptyId) - if (ownedConnectionId !== undefined) { - return getProvider(ownedConnectionId) +function closeStartupQueryAuthorityForPty(ptyId: string): void { + try { + void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch( + () => {} + ) + } catch { + /* Best-effort handoff; the bounded source deadline remains the fallback. */ } - const parsedSshId = parseAppSshPtyId(ptyId) - if (parsedSshId) { - return getProvider(parsedSshId.connectionId) - } - return localProvider -} - -export function answerStartupTerminalColorQueriesForPty(ptyId: string, data: string): string { - return answerStartupTerminalColorQueries(ptyId, data, getProviderForStartupTerminalColorReply) } function normalizeNodePtySpawnError(err: unknown): Error { @@ -1189,7 +1177,6 @@ export function clearProviderPtyState(id: string): void { rendererVisibilityKnownPtys.delete(id) pendingHiddenRendererResizeOutputPtys.delete(id) deliveredHiddenRendererResizeOutputPtys.delete(id) - clearStartupTerminalColorQueryReplies(id) // Why: every PTY teardown path funnels through here (local exit, daemon // shutdown, SSH exit/connection teardown) — hidden/interest gate bits must // not outlive the PTY or a reused map entry could silently gate a new one. @@ -1637,7 +1624,8 @@ export function registerPtyHandlers( markClaudePtyExited(id) runtime?.onPtyExit(id, code) }, - onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp) + onData: (id, data, timestamp, sequenceChars, transformed) => + runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) }) } @@ -1648,6 +1636,8 @@ export function registerPtyHandlers( type PendingPtyData = { data: string startSeq?: number + rawLength?: number + transformed?: true containsBackgroundOutput?: boolean // Why droppedOutput (not main's droppedBacklog trim): this branch bounds // the unsent backlog with the O(1) drop-to-sentinel + query-salvage + @@ -1662,6 +1652,7 @@ export function registerPtyHandlers( data: string seq?: number rawLength?: number + transformed?: boolean background?: boolean droppedOutput?: boolean } @@ -2057,20 +2048,23 @@ export function registerPtyHandlers( return true } - function getChunkStartSeq(endSeq: number | undefined, data: string): number | undefined { - return typeof endSeq === 'number' ? Math.max(0, endSeq - data.length) : undefined - } - function makePtyDataPayload( id: string, data: string, startSeq: number | undefined, - containsBackgroundOutput: boolean | undefined + containsBackgroundOutput: boolean | undefined, + rawLength = data.length, + transformed = false ): PtyDataPayload { const payload: PtyDataPayload = { id, data } if (typeof startSeq === 'number') { - payload.seq = startSeq + data.length - payload.rawLength = data.length + payload.seq = startSeq + rawLength + } + if (typeof startSeq === 'number' || rawLength !== data.length || transformed) { + payload.rawLength = rawLength + } + if (transformed) { + payload.transformed = true } if (containsBackgroundOutput === true) { payload.background = true @@ -2362,7 +2356,9 @@ export function registerPtyHandlers( data: string, startSeq: number | undefined, preservesSeq: boolean, - containsBackgroundOutput: boolean + containsBackgroundOutput: boolean, + rawLength = data.length, + transformed = false ): PendingPtyData { // Why: once over the cap, stay dropped at O(1) memory until the renderer // can receive again — the restore sentinel supersedes any interim bytes. @@ -2377,21 +2373,21 @@ export function registerPtyHandlers( } const nextContainsBackgroundOutput = existing?.containsBackgroundOutput === true || containsBackgroundOutput - if (!preservesSeq) { - return dropOversizedPendingPtyData(id, { - data: (existing?.data ?? '') + data, - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) - }) - } if (!existing) { return dropOversizedPendingPtyData(id, { data, ...(typeof startSeq === 'number' ? { startSeq } : {}), + ...(rawLength !== data.length ? { rawLength } : {}), + ...(transformed ? { transformed: true } : {}), ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) }) } + const existingRawLength = existing.rawLength ?? existing.data.length const next: PendingPtyData = { data: existing.data + data, + ...(!preservesSeq || existing.transformed || transformed + ? { rawLength: existingRawLength + rawLength, transformed: true as const } + : {}), ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) } if (typeof existing.startSeq === 'number') { @@ -2490,8 +2486,9 @@ export function registerPtyHandlers( continue } const { data } = pending - const chunk = data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) - const remaining = data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) + const indivisible = pending.transformed === true + const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) + const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) if (remaining) { const nextPending: PendingPtyData = { data: remaining } if (typeof pending.startSeq === 'number') { @@ -2507,7 +2504,14 @@ export function registerPtyHandlers( updateProducerFlowControl(id) sendPtyDataToRenderer( id, - makePtyDataPayload(id, chunk, pending.startSeq, pending.containsBackgroundOutput) + makePtyDataPayload( + id, + chunk, + pending.startSeq, + pending.containsBackgroundOutput, + pending.rawLength, + pending.transformed + ) ) writes++ } @@ -2580,7 +2584,9 @@ export function registerPtyHandlers( payload.id, remaining.data, remaining.startSeq, - remaining.containsBackgroundOutput + remaining.containsBackgroundOutput, + remaining.rawLength, + remaining.transformed ) ) } @@ -2668,19 +2674,14 @@ export function registerPtyHandlers( const isLocalProvider = localProvider instanceof LocalPtyProvider localDataUnsub = localProvider.onData((payload) => { + const rawLength = payload.sequenceChars ?? payload.data.length const outputSeq = isLocalProvider ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData( - payload.id, - payload.data, - Date.now(), - payload.sequenceChars ?? payload.data.length - ) - const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) - const preservesSeq = - rendererData === payload.data && - (payload.sequenceChars === undefined || payload.sequenceChars === payload.data.length) - const startSeq = preservesSeq ? getChunkStartSeq(outputSeq, payload.data) : undefined + : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) + const rendererData = payload.data + const preservesSeq = !payload.transformed && rawLength === payload.data.length + const startSeq = + typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window // is gone. Without this, macOS app re-activation leaks orphaned timers @@ -2713,7 +2714,7 @@ export function registerPtyHandlers( } return } - if (rendererData.length === 0) { + if (rendererData.length === 0 && !payload.transformed) { return } const containsBackgroundOutput = @@ -2728,7 +2729,9 @@ export function registerPtyHandlers( rendererData, startSeq, preservesSeq, - containsBackgroundOutput + containsBackgroundOutput, + rawLength, + payload.transformed === true ) const nextData = pending.data const isInteractiveOutput = shouldSendInteractiveOutputNow( @@ -2760,8 +2763,12 @@ export function registerPtyHandlers( id: payload.id, data: nextData, ...(typeof pending.startSeq === 'number' - ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } + ? { + seq: pending.startSeq + (pending.rawLength ?? nextData.length), + rawLength: pending.rawLength ?? nextData.length + } : {}), + ...(pending.transformed ? { transformed: true } : {}), ...(pending.containsBackgroundOutput === true ? { background: true } : {}), ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) }) @@ -3904,10 +3911,6 @@ export function registerPtyHandlers( })?.distro ?? null) : null const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - const preSpawnStartupTerminalColorReplyPtyId = - startupTerminalColorQueryReplyColors && effectiveSessionId !== undefined - ? (effectiveSessionAppId ?? effectiveSessionId) - : null // Why: the renderer sets pane env for SSH too. Only forward it to the // remote when the relay hook path is enabled; otherwise a newer relay // could emit statuses this Orca build is not prepared to route. @@ -4015,6 +4018,11 @@ export function registerPtyHandlers( const validatedLeafId = verifiedLeafId ?? metadataLeafId let env: Record | undefined = baseEnv const effectiveShellOverride = terminalRuntimeOptions.shellOverride + const nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: effectiveShellOverride + }) const codexSelectionTarget = getCodexSelectionTargetForPty( effectiveShellOverride, cwd, @@ -4169,6 +4177,15 @@ export function registerPtyHandlers( ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined } + if (startupTerminalColorQueryReplyColors) { + spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000, + ...(nativeWindowsConptySpawn + ? { echoProjection: 'windows-conpty-esc-stripped' as const } + : {}) + } + } const existingPaneSpawn = reservationPaneKey ? paneSpawnReservationsByPaneKey.get(reservationPaneKey) : undefined @@ -4202,14 +4219,6 @@ export function registerPtyHandlers( if (preAllocatedHandle) { trustedTerminalHandleEnv.add(preAllocatedHandle) } - if (preSpawnStartupTerminalColorReplyPtyId && startupTerminalColorQueryReplyColors) { - // Why: Codex probes OSC 10/11 with a 100 ms timeout and daemon PTYs - // can emit that query before spawn() resolves to the renderer. - registerStartupTerminalColorQueryReplies( - preSpawnStartupTerminalColorReplyPtyId, - startupTerminalColorQueryReplyColors - ) - } spawnTiming.mark('options') const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId if (isDaemonHostSpawn && expectedPtyId) { @@ -4254,9 +4263,6 @@ export function registerPtyHandlers( const spawnError = normalizeNodePtySpawnError(err) const isIdentityMismatch = isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) - if (preSpawnStartupTerminalColorReplyPtyId) { - clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId) - } if (effectiveSessionAppId !== undefined) { if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) { ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach) @@ -4337,33 +4343,14 @@ export function registerPtyHandlers( // ownership, and a hidden-spawned agent should be paceable from its // first flood, not from its first visibility transition. syncPtyBackgroundedDelivery(result.id, 'spawn') + closeStartupQueryAuthorityForPty(result.id) } // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY // determination from the spawn record before the headless seed below, // so the runtime emulator's DA1 override exists from byte zero. - if ( - isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: effectiveShellOverride - }) - ) { + if (nativeWindowsConptySpawn) { markNativeWindowsConptyPty(result.id) } - if (startupTerminalColorQueryReplyColors) { - if (result.isReattach) { - if (preSpawnStartupTerminalColorReplyPtyId) { - clearStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId) - } - } else if (preSpawnStartupTerminalColorReplyPtyId) { - moveStartupTerminalColorQueryReplies(preSpawnStartupTerminalColorReplyPtyId, result.id) - } else { - registerStartupTerminalColorQueryReplies( - result.id, - startupTerminalColorQueryReplyColors - ) - } - } const relayResultId = getRelayPtyId(args.connectionId, result.id) if (store && args.connectionId) { // Why: remote PTYs live in the SSH relay grace window after Orca @@ -5085,6 +5072,7 @@ export function registerPtyHandlers( rendererVisibilityKnownPtys.add(args.id) if (args.visible) { visibleRendererPtys.add(args.id) + closeStartupQueryAuthorityForPty(args.id) } else { visibleRendererPtys.delete(args.id) } @@ -5101,6 +5089,7 @@ export function registerPtyHandlers( }) if (args.hidden === true) { markHiddenRendererPty(args.id) + closeStartupQueryAuthorityForPty(args.id) // Why: bytes already queued for a newly hidden PTY are model-owned // state; drop them now instead of holding them under ACK starvation. // Reveal restores from the snapshot. diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index c925f592aa2..cbca2db9d2a 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -161,7 +161,6 @@ vi.mock('./pty', () => ({ clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), setPtyOwnership: vi.fn(), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data), getSshPtyProvider: vi.fn(), getPtyIdsForConnection: vi.fn().mockReturnValue([]), isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) @@ -809,7 +808,13 @@ describe('SSH IPC handlers', () => { onData?.({ id: 'remote-pty', data: 'hello' }) onExit?.({ id: 'remote-pty', code: 7 }) - expect(runtime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number)) + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'remote-pty', + 'hello', + expect.any(Number), + 'hello'.length, + undefined + ) expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) }) @@ -1180,7 +1185,13 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })]) }) - expect(secondRuntime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number)) + expect(secondRuntime.onPtyData).toHaveBeenCalledWith( + 'remote-pty', + 'hello', + expect.any(Number), + 'hello'.length, + undefined + ) expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9) expect(firstRuntime.onPtyData).not.toHaveBeenCalled() expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() diff --git a/src/main/ipc/terminal-startup-color-query-replies.ts b/src/main/ipc/terminal-startup-color-query-replies.ts index 5bcee8208bb..6d6b8ebff42 100644 --- a/src/main/ipc/terminal-startup-color-query-replies.ts +++ b/src/main/ipc/terminal-startup-color-query-replies.ts @@ -3,71 +3,10 @@ import { isTuiAgent } from '../../shared/tui-agent-config' import { agentKindSchema } from '../../shared/telemetry-events' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import { - parseTerminalOscColorQuery, - terminalOscColorQueryReplies, terminalOscColorQueryReply, - type TerminalOscColorQueryReplyColors, - type TerminalOscColorQuerySlot + type TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' -type StartupTerminalColorQueryProvider = { - write(id: string, data: string): void -} - -type StartupTerminalColorQueryReplyState = { - colors: TerminalOscColorQueryReplyColors - pending: string - answeredSlots: Set - timeout: ReturnType -} - -const STARTUP_TERMINAL_COLOR_QUERY_REPLY_WINDOW_MS = 5_000 -const STARTUP_TERMINAL_COLOR_QUERY_PENDING_CHARS = 64 -const startupTerminalColorQueryRepliesByPty = new Map() - -export function clearStartupTerminalColorQueryReplies(ptyId: string): void { - const state = startupTerminalColorQueryRepliesByPty.get(ptyId) - if (!state) { - return - } - clearTimeout(state.timeout) - startupTerminalColorQueryRepliesByPty.delete(ptyId) -} - -export function moveStartupTerminalColorQueryReplies(fromPtyId: string, toPtyId: string): void { - if (fromPtyId === toPtyId) { - return - } - const state = startupTerminalColorQueryRepliesByPty.get(fromPtyId) - if (!state) { - return - } - startupTerminalColorQueryRepliesByPty.delete(fromPtyId) - clearStartupTerminalColorQueryReplies(toPtyId) - startupTerminalColorQueryRepliesByPty.set(toPtyId, state) -} - -export function registerStartupTerminalColorQueryReplies( - ptyId: string, - colors: TerminalOscColorQueryReplyColors -): void { - if (!terminalOscColorQueryReply(colors, 10) || !terminalOscColorQueryReply(colors, 11)) { - return - } - clearStartupTerminalColorQueryReplies(ptyId) - const timeout = setTimeout( - () => clearStartupTerminalColorQueryReplies(ptyId), - STARTUP_TERMINAL_COLOR_QUERY_REPLY_WINDOW_MS - ) - timeout.unref?.() - startupTerminalColorQueryRepliesByPty.set(ptyId, { - colors, - pending: '', - answeredSlots: new Set(), - timeout - }) -} - function normalizeTerminalColorQueryReplyColors( value: unknown ): TerminalOscColorQueryReplyColors | null { @@ -117,81 +56,3 @@ export function getStartupTerminalColorQueryReplyColors(args: { } return normalizeTerminalColorQueryReplyColors(args.terminalColorQueryReplies) } - -function writeStartupTerminalColorQueryReplies( - ptyId: string, - slots: readonly TerminalOscColorQuerySlot[], - state: StartupTerminalColorQueryReplyState, - getProvider: (ptyId: string) => StartupTerminalColorQueryProvider | undefined -): boolean { - const replies = terminalOscColorQueryReplies(state.colors, slots) - let provider: StartupTerminalColorQueryProvider | undefined - try { - provider = replies ? getProvider(ptyId) : undefined - } catch { - provider = undefined - } - if (!replies || !provider) { - return false - } - try { - for (const [index, reply] of replies.entries()) { - const slot = slots[index] - if (slot === undefined) { - return false - } - provider.write(ptyId, reply) - state.answeredSlots.add(slot) - } - return true - } catch { - return false - } -} - -export function answerStartupTerminalColorQueries( - ptyId: string, - data: string, - getProvider: (ptyId: string) => StartupTerminalColorQueryProvider | undefined -): string { - const state = startupTerminalColorQueryRepliesByPty.get(ptyId) - if (!state || data.length === 0) { - return data - } - const input = state.pending + data - let pending = '' - let output = '' - let offset = 0 - while (offset < input.length) { - const candidateIndex = input.indexOf('\x1b', offset) - if (candidateIndex === -1) { - output += input.slice(offset) - break - } - output += input.slice(offset, candidateIndex) - const query = parseTerminalOscColorQuery(input, candidateIndex) - if (query.kind === 'none') { - output += input[candidateIndex] - offset = candidateIndex + 1 - continue - } - if (query.kind === 'partial') { - const candidate = input.slice(candidateIndex) - if (candidate.length <= STARTUP_TERMINAL_COLOR_QUERY_PENDING_CHARS) { - pending = candidate - } else { - output += candidate - } - break - } - if (!writeStartupTerminalColorQueryReplies(ptyId, query.slots, state, getProvider)) { - output += input.slice(candidateIndex, query.endIndex) - } - offset = query.endIndex - } - state.pending = pending - if (state.answeredSlots.has(10) && state.answeredSlots.has(11)) { - clearStartupTerminalColorQueryReplies(ptyId) - } - return output -} diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index fcd6235b101..3eaa8c557d6 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -1582,6 +1582,47 @@ describe('LocalPtyProvider', () => { expect(dataHandler).toHaveBeenCalledWith({ id, data: 'hello world' }) }) + it('classifies startup queries before runtime and public data listeners', async () => { + const runtimeData = vi.fn() + const dataHandler = vi.fn() + provider.configure({ onData: runtimeData }) + provider.onData(dataHandler) + const { id } = await provider.spawn({ + cols: 80, + rows: 24, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000, + echoProjection: 'windows-conpty-esc-stripped' + } + }) + const onDataCb = mockProc.onData.mock.calls[0][0] + const query = '\x1b]10;?\x07' + const echo = ']10;rgb:2e2e/3434/3434\\' + + onDataCb(query) + onDataCb(echo) + onDataCb('prompt') + + expect(mockProc.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\') + expect(runtimeData.mock.calls.map((call) => call.slice(1))).toEqual([ + ['', expect.any(Number), query.length, true], + ['', expect.any(Number), echo.length, true], + ['prompt', expect.any(Number)] + ]) + expect(dataHandler.mock.calls.map(([payload]) => payload)).toEqual([ + { id, data: '', sequenceChars: query.length, seq: query.length, transformed: true }, + { + id, + data: '', + sequenceChars: echo.length, + seq: query.length + echo.length, + transformed: true + }, + { id, data: 'prompt' } + ]) + }) + it('notifies exit listeners when PTY exits', async () => { const exitHandler = vi.fn() provider.onExit(exitHandler) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 28c5586152c..4cc456bd799 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -68,6 +68,7 @@ import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-defau import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' +import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -122,11 +123,18 @@ export const LOCAL_PTY_FORCE_KILL_RETRY_MS = 250 let loadGeneration = 0 const ptyLoadGeneration = new Map() -type DataCallback = (payload: { id: string; data: string }) => void +type DataCallback = (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number +}) => void type ExitCallback = (payload: { id: string; code: number }) => void const dataListeners = new Set() const exitListeners = new Set() +const startupIngressByPty = new Map() /** * Returns a stable default cwd for locally spawned PTYs. @@ -492,7 +500,13 @@ export type LocalPtyProviderOptions = { pwshAvailable?: () => boolean onSpawned?: (id: string) => void onExit?: (id: string, code: number) => void - onData?: (id: string, data: string, timestamp: number) => void + onData?: ( + id: string, + data: string, + timestamp: number, + sequenceChars?: number, + transformed?: boolean + ) => void } export class LocalPtyProvider implements IPtyProvider { @@ -907,6 +921,34 @@ export class LocalPtyProvider implements IPtyProvider { ptyLoadGeneration.set(id, loadGeneration) this.opts.onSpawned?.(id) + const emitIngressData = (emission: PtyIngressEmission): void => { + const sequenceChars = emission.rawEndSeq - emission.rawStartSeq + if (emission.transformed || sequenceChars !== emission.data.length) { + this.opts.onData?.(id, emission.data, Date.now(), sequenceChars, true) + } else { + this.opts.onData?.(id, emission.data, Date.now()) + } + for (const cb of dataListeners) { + cb( + emission.transformed || sequenceChars !== emission.data.length + ? { + id, + data: emission.data, + sequenceChars, + seq: emission.rawEndSeq, + transformed: true + } + : { id, data: emission.data } + ) + } + } + const startupIngress = new PtyStartupIngress({ + ...(args.startupIngress ? { intent: args.startupIngress } : {}), + write: (data) => proc.write(data), + onEmission: emitIngressData + }) + startupIngressByPty.set(id, startupIngress) + // Shell-ready startup command support let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null let shellReadyTimeout: ReturnType | null = null @@ -938,10 +980,7 @@ export class LocalPtyProvider implements IPtyProvider { if (heldBytes.length === 0) { return } - this.opts.onData?.(id, heldBytes, Date.now()) - for (const cb of dataListeners) { - cb({ id, data: heldBytes }) - } + startupIngress.accept(heldBytes) } if (args.command) { if (shellReadyLaunch?.supportsReadyMarker) { @@ -977,13 +1016,7 @@ export class LocalPtyProvider implements IPtyProvider { finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved }) } } - if (data.length === 0) { - return - } - this.opts.onData?.(id, data, Date.now()) - for (const cb of dataListeners) { - cb({ id, data }) - } + startupIngress.accept(data) }) if (onDataDisposable) { disposables.push(onDataDisposable) @@ -1010,6 +1043,8 @@ export class LocalPtyProvider implements IPtyProvider { } startupCommandCleanup?.() clearPtyState(id) + startupIngress.drainAndClose() + startupIngressByPty.delete(id) // Why: release the master ptmx fd on the natural-exit path — without // this, a shell that exits cleanly (the common case) never releases its // fd until the next GC. See docs/fix-pty-fd-leak.md. @@ -1223,11 +1258,15 @@ export class LocalPtyProvider implements IPtyProvider { // only safe at an empty prompt, and without a headless emulator this // provider cannot tell whether input is pending. try { + startupIngressByPty.get(id)?.snapshotBarrier() ptyProcesses.get(id)?.clear() } catch { /* PTY may have just exited */ } } + closeStartupQueryAuthority(id: string): number { + return startupIngressByPty.get(id)?.closeQueryAuthority() ?? 0 + } acknowledgeDataEvent(_id: string, _charCount: number): void { /* no flow control for local */ } diff --git a/src/main/providers/pty-provider-events.ts b/src/main/providers/pty-provider-events.ts new file mode 100644 index 00000000000..6de435cb23f --- /dev/null +++ b/src/main/providers/pty-provider-events.ts @@ -0,0 +1,22 @@ +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' + +export type PtyDataEvent = { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number +} + +/** Notification-bearing fact a thinning transport detected while it held + * scan authority for a backgrounded PTY (see onBackgroundStreamEvent). */ +export type PtyTransientFact = + | { kind: 'bell' } + | { kind: 'command-finished'; exitCode: number | null } + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + | { kind: '2031-subscribe' } + +export type PtyBackgroundStreamEvent = + | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string } + | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number } + | { id: string; kind: 'transientFact'; fact: PtyTransientFact } diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 375aeb86b54..ccbc60edcee 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -49,6 +49,21 @@ describe('SshPtyProvider', () => { expect(result).toEqual({ id: scopedPty1 }) }) + it('gates fresh startup intent with the relay ingress capability version', async () => { + mux.request.mockResolvedValue({ id: 'pty-1' }) + const startupIngress = { + colors: { foreground: '#eeeeee', background: '#111111' }, + deadlineMs: 5_000 + } + + await provider.spawn({ cols: 80, rows: 24, startupIngress }) + + expect(mux.request).toHaveBeenCalledWith( + 'pty.spawn', + expect.objectContaining({ startupIngressVersion: 1, startupIngress }) + ) + }) + it('passes cwd and env through', async () => { mux.request.mockResolvedValue({ id: 'pty-2' }) @@ -324,6 +339,26 @@ describe('SshPtyProvider', () => { }) }) + it('never sends fresh startup intent on relay reattach', async () => { + mux.request.mockResolvedValue({ replay: 'restored' }) + + await provider.spawn({ + cols: 80, + rows: 24, + sessionId: scopedPty1, + startupIngress: { + colors: { foreground: '#eeeeee', background: '#111111' }, + deadlineMs: 5_000 + } + }) + + expect(mux.request).toHaveBeenCalledWith( + 'pty.attach', + expect.not.objectContaining({ startupIngress: expect.anything() }) + ) + expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything()) + }) + it('reattaches scoped app ids using raw relay ids', async () => { mux.request.mockResolvedValue({ replay: 'buffered-output' }) @@ -531,6 +566,28 @@ describe('SshPtyProvider', () => { expect(handler).toHaveBeenCalledWith({ id: scopedPty1, data: 'output' }) }) + it('forwards empty transformed relay spans without reinterpreting them', () => { + const handler = vi.fn() + provider.onData(handler) + const notifHandler = mux.onNotification.mock.calls[0][0] + + notifHandler('pty.data', { + id: 'pty-1', + data: '', + rawLength: 9, + seq: 9, + transformed: true + }) + + expect(handler).toHaveBeenCalledWith({ + id: scopedPty1, + data: '', + sequenceChars: 9, + seq: 9, + transformed: true + }) + }) + it('forwards pty.replay notifications to replay listeners', () => { const handler = vi.fn() provider.onReplay(handler) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index bdc81787996..1182104cefa 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -2,8 +2,15 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' +import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' -type DataCallback = (payload: { id: string; data: string }) => void +type DataCallback = (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number +}) => void type ReplayCallback = (payload: { id: string; data: string }) => void type ExitCallback = (payload: { id: string; code: number }) => void type RemoteCliBridgeEnv = { @@ -56,7 +63,15 @@ export class SshPtyProvider implements IPtyProvider { switch (method) { case 'pty.data': for (const cb of this.dataListeners) { - cb({ id: this.toAppPtyId(params.id as string), data: params.data as string }) + cb({ + id: this.toAppPtyId(params.id as string), + data: params.data as string, + ...(typeof params.rawLength === 'number' + ? { sequenceChars: params.rawLength as number } + : {}), + ...(params.transformed === true ? { transformed: true } : {}), + ...(typeof params.seq === 'number' ? { seq: params.seq as number } : {}) + }) } break @@ -167,7 +182,13 @@ export class SshPtyProvider implements IPtyProvider { // remote hooks are disabled, but the relay still needs attach identity // metadata to reject cross-generation PTY id collisions. ...(opts.paneKey ? { paneKey: opts.paneKey } : {}), - ...(opts.tabId ? { tabId: opts.tabId } : {}) + ...(opts.tabId ? { tabId: opts.tabId } : {}), + ...(opts.startupIngress + ? { + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + startupIngress: opts.startupIngress + } + : {}) }) return { ...(result as PtySpawnResult), @@ -261,6 +282,13 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.clearBuffer', { id: this.toRelayPtyId(id) }) } + async closeStartupQueryAuthority(id: string): Promise { + const result = (await this.mux.request('pty.closeStartupQueryAuthority', { + id: this.toRelayPtyId(id) + })) as { appliedSeq?: number } + return result.appliedSeq ?? 0 + } + acknowledgeDataEvent(id: string, charCount: number): void { this.mux.notify('pty.ackData', { id: this.toRelayPtyId(id), charCount }) } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 6941e4c3063..ea2ea2c57dc 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -18,29 +18,23 @@ import type { SearchResult } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' -import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import type { GitProviderStatusOptions } from './git-provider-status-options' +import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events' import type { PtySpawnResult } from './pty-spawn-result' +export type { + PtyBackgroundStreamEvent, + PtyDataEvent, + PtyTransientFact +} from './pty-provider-events' + // ─── PTY Provider ─────────────────────────────────────────────────── -/** Notification-bearing fact a thinning transport detected while it held - * scan authority for a backgrounded PTY (see onBackgroundStreamEvent). */ -export type PtyTransientFact = - | { kind: 'bell' } - | { kind: 'command-finished'; exitCode: number | null } - | { kind: 'pr-link'; link: TerminalGitHubPRLink } - | { kind: '2031-subscribe' } - -export type PtyBackgroundStreamEvent = - | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string } - | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number } - | { id: string; kind: 'transientFact'; fact: PtyTransientFact } - export type PtyProviderBufferSnapshot = { data: string /** Authoritative normal buffer captured beside an alternate-screen frame. */ @@ -98,6 +92,8 @@ export type PtySpawnOptions = { * through spawn options keeps local PTY and daemon PTY semantics aligned * without promoting pwsh into a separate shell family. */ terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + /** Fresh-spawn-only source authority installed before any PTY output is released. */ + startupIngress?: PtyStartupIngressIntent } export type { PtySpawnResult } @@ -170,6 +166,8 @@ export type IPtyProvider = { getCwd(id: string): Promise getInitialCwd(id: string): Promise clearBuffer(id: string): Promise + /** Ordered handoff from startup source authority to the live/hidden view authority. */ + closeStartupQueryAuthority?: (id: string) => Promise | number acknowledgeDataEvent(id: string, charCount: number): void hasChildProcesses(id: string): Promise getForegroundProcess(id: string): Promise @@ -180,9 +178,7 @@ export type IPtyProvider = { listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> - onData( - callback: (payload: { id: string; data: string; sequenceChars?: number }) => void - ): () => void + onData(callback: (payload: PtyDataEvent) => void): () => void onReplay(callback: (payload: { id: string; data: string }) => void): () => void onExit(callback: (payload: { id: string; code: number }) => void): () => void } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9231beb040a..b5e2f0df995 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2282,7 +2282,12 @@ export class OrcaRuntimeService { // without polling. Keyed by ptyId for O(1) lookup per data event. private dataListeners = new Map< string, - Set<(data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void> + Set< + ( + data: string, + meta?: { seq?: number; rawLength?: number; transformed?: boolean; cwd?: string } + ) => void + > >() // Why: startup draft paste can subscribe after the agent already emitted its // ready marker. Keep a bounded raw buffer so fast startup output is replayed. @@ -6064,7 +6069,13 @@ export class OrcaRuntimeService { * Handles incoming data from a PTY process, running agent detection, * updating terminal tail buffers, and triggering foreground agent refreshes. */ - onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number { + onPtyData( + ptyId: string, + data: string, + at: number, + sequenceChars = data.length, + transformed = false + ): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) @@ -6282,7 +6293,8 @@ export class OrcaRuntimeService { if (listeners) { const meta = { seq: outputSequence, - rawLength: data.length, + rawLength: sequenceChars, + ...(transformed ? { transformed: true } : {}), ...(cwdChanged && cwd !== null ? { cwd } : {}) } for (const listener of listeners) { @@ -7133,7 +7145,10 @@ export class OrcaRuntimeService { subscribeToTerminalData( ptyId: string, - listener: (data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void + listener: ( + data: string, + meta?: { seq?: number; rawLength?: number; transformed?: boolean; cwd?: string } + ) => void ): () => void { return addListenerToMap(this.dataListeners, ptyId, listener) } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index b0983d5e06f..05c5045c50b 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -140,11 +140,17 @@ type TerminalOutputChunk = { meta?: TerminalOutputMeta } -type TerminalOutputMeta = { seq?: number; rawLength?: number; cwd?: string } +type TerminalOutputMeta = { + seq?: number + rawLength?: number + transformed?: boolean + cwd?: string +} type TerminalOutputFrameChunk = { bytes: Uint8Array seq?: number + opcode?: TerminalStreamOpcode } function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { @@ -156,6 +162,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp let bytes = 0 let lastSeq: number | undefined let pendingCwd: string | undefined + let pendingRawLength = 0 let timer: ReturnType | null = null const clearTimer = (): void => { @@ -168,14 +175,14 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp const flush = (): void => { clearTimer() - if (chunks.length === 0) { + if (chunks.length === 0 && pendingRawLength === 0) { return } const data = chunks.length === 1 ? chunks[0]! : chunks.join('') const meta = typeof lastSeq === 'number' || pendingCwd !== undefined ? { - ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: data.length } : {}), + ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}) } : undefined @@ -183,12 +190,19 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp bytes = 0 lastSeq = undefined pendingCwd = undefined + pendingRawLength = 0 onFlush(data, meta) } return { push(data: string, meta?: TerminalOutputMeta): void { - if (!data) { + const rawLength = meta?.rawLength ?? data.length + if (!data && rawLength === 0) { + return + } + if (meta?.transformed || rawLength !== data.length) { + flush() + onFlush(data, { ...meta, rawLength, transformed: true }) return } if (meta?.cwd !== undefined) { @@ -196,6 +210,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp pendingCwd = meta.cwd } chunks.push(data) + pendingRawLength += rawLength const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) const measurement = measureTerminalStreamByteLength(data, { stopAfterBytes: remainingBudget @@ -222,6 +237,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp clearTimer() chunks = [] bytes = 0 + pendingRawLength = 0 } } } @@ -230,11 +246,19 @@ function* iterateTerminalOutputFrameChunks( data: string, meta?: TerminalOutputMeta ): Generator { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } return } - const rawLength = meta?.rawLength ?? data.length const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined @@ -1640,7 +1664,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk ): void => { - sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + sendFrame( + stream.streamId, + chunk.opcode ?? TerminalStreamOpcode.Output, + chunk.bytes, + chunk.seq + ) if (stream.ackOutput) { stream.ackInFlightBytes += chunk.bytes.byteLength ackTotalInFlightBytes += chunk.bytes.byteLength @@ -2802,7 +2831,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) } for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - sendFrame(TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) } }) unregisterBinaryHandler = @@ -3199,6 +3228,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!initialOutputOverflowed) { for (const item of bufferedOutput) { let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + let uncoveredMeta = item.meta if ( uncoveredData && uncoveredData !== item.data && @@ -3206,6 +3236,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ typeof item.meta?.seq === 'number' && typeof item.meta.rawLength === 'number' ) { + if (item.meta.rawLength === item.data.length) { + uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } + } uncoveredData = stripSnapshotBoundaryQuerySuffixes( uncoveredData, snapshotOutputSeq, @@ -3214,7 +3247,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) } if (uncoveredData) { - outputBatcher.push(uncoveredData, item.meta) + outputBatcher.push(uncoveredData, uncoveredMeta) } } } diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts new file mode 100644 index 00000000000..901f7940d31 --- /dev/null +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshRelaySession } from './ssh-relay-session' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' + +const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() })) + +vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) + +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + notify = vi.fn() + request = muxRequestMock + onNotification = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) + onDispose = vi.fn().mockReturnValue(() => {}) + dispose = vi.fn() + isDisposed = vi.fn().mockReturnValue(false) + } +})) + +vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ + installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../providers/ssh-pty-provider', () => ({ + isSshPtyNotFoundError: vi.fn().mockReturnValue(false), + isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false), + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) + dispose = vi.fn() + } +})) + +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + dispose = vi.fn() + } +})) + +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider {} +})) + +vi.mock('../ipc/pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), + setPtyOwnership: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +const { registerSshPtyProvider } = await import('../ipc/pty') + +describe('SshRelaySession data delivery', () => { + beforeEach(() => { + vi.clearAllMocks() + muxRequestMock.mockResolvedValue([]) + mockDeploySuccess() + }) + + it('delivers empty transformed relay spans with raw sequence metadata', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { onPtyData: vi.fn(() => 17), onPtyExit: vi.fn() } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + }) => void + + onData({ id: 'ssh-pty-1', data: '', sequenceChars: 9, transformed: true }) + + expect(runtime.onPtyData).toHaveBeenCalledWith('ssh-pty-1', '', expect.any(Number), 9, true) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: 'ssh-pty-1', + data: '', + sequenceChars: 9, + transformed: true, + seq: 17, + rawLength: 9 + }) + }) +}) diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 56d12ec9cef..ce9317c6660 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -65,8 +65,7 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) + setPtyOwnership: vi.fn() })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index da490ae7be0..55abd17b9c6 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -73,8 +73,7 @@ vi.mock('../ipc/pty', () => ({ clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), deletePtyOwnership: vi.fn(), - setPtyOwnership: vi.fn(), - answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) + setPtyOwnership: vi.fn() })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ @@ -152,7 +151,9 @@ describe('SshRelaySession', () => { expect(runtime.onPtyData).toHaveBeenCalledWith( 'ssh-pty-1', 'hidden ssh output', - expect.any(Number) + expect.any(Number), + 'hidden ssh output'.length, + undefined ) expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) // Why out-of-band: an in-band empty pty:data sentinel is ambiguous with @@ -207,7 +208,8 @@ describe('SshRelaySession', () => { expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: 'ssh-pty-1', - data: 'still delivered' + data: 'still delivered', + rawLength: 'still delivered'.length }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 969878c1220..b9c41c84c1d 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -41,8 +41,7 @@ import { clearPtyOwnershipForConnection, clearProviderPtyState, deletePtyOwnership, - setPtyOwnership, - answerStartupTerminalColorQueriesForPty + setPtyOwnership } from '../ipc/pty' import { recordHiddenRendererPtyDataDrop, @@ -1120,8 +1119,14 @@ export class SshRelaySession { private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { ptyProvider.onData((payload) => { - const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) - const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) + const rawLength = payload.sequenceChars ?? payload.data.length + const seq = this.runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + rawLength, + payload.transformed + ) const win = this.getMainWindow() if (!win || win.isDestroyed()) { return @@ -1133,7 +1138,7 @@ export class SshRelaySession { // OSC-9999-only chunks legitimately strip to empty in the renderer. const store = this.store as { getSettings?: Store['getSettings'] } if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { - const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + const drop = recordHiddenRendererPtyDataDrop(payload.id, rawLength) if (drop.shouldEmitRestoreMarker) { win.webContents.send('pty:modelRestoreNeeded', { id: payload.id, @@ -1143,16 +1148,12 @@ export class SshRelaySession { } return } - // Why: startup color-query answering can strip query-only chunks to - // empty; skip empty sends and only attach seq metadata when the chunk - // reaches the renderer unmodified (seq tracks raw stream offsets). - if (rendererData.length > 0) { + if (payload.data.length > 0 || payload.transformed) { win.webContents.send('pty:data', { ...payload, - data: rendererData, - ...(rendererData === payload.data && typeof seq === 'number' - ? { seq, rawLength: payload.data.length } - : {}) + ...(typeof seq === 'number' ? { seq } : {}), + rawLength, + ...(payload.transformed ? { transformed: true } : {}) }) } }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3911de36f9d..487f7b2e0dd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1439,6 +1439,7 @@ export type PreloadApi = { data: string seq?: number rawLength?: number + transformed?: boolean background?: boolean droppedOutput?: boolean }) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index dc8023b1e85..5571f9349b9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1061,6 +1061,7 @@ const api = { data: string seq?: number rawLength?: number + transformed?: boolean background?: boolean droppedOutput?: boolean }) => void @@ -1072,6 +1073,7 @@ const api = { data: string seq?: number rawLength?: number + transformed?: boolean background?: boolean droppedOutput?: boolean } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 8de7ec8051b..0c0b33d9b94 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -10,6 +10,7 @@ import { resolveSetupAgentSequenceLaunchCommand, SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../shared/setup-agent-sequencing' +import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -968,6 +969,72 @@ describe('PtyHandler', () => { expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'hello world' }) }) + it('consumes capable startup queries before relay replay and fanout', async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 + } + }) + + const query = '\x1b]10;?\x07' + dataCallback!(query) + dataCallback!('prompt') + vi.advanceTimersByTime(8) + + expect(term.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\') + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { + id: 'pty-1', + data: '', + rawLength: query.length, + seq: query.length, + transformed: true + }) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: 'prompt' }) + await expect( + dispatcher.callRequest('pty.attach', { + id: 'pty-1', + suppressReplayNotification: true + }) + ).resolves.toEqual({ replay: 'prompt' }) + }) + + it('leaves startup queries untouched for an unsupported relay capability version', async () => { + let dataCallback: ((data: string) => void) | undefined + const term = { + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + } + mockPtySpawn.mockReturnValue(term) + await dispatcher.callRequest('pty.spawn', { + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION - 1, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 + } + }) + + const query = '\x1b]10;?\x07' + dataCallback!(query) + vi.advanceTimersByTime(8) + + expect(term.write).not.toHaveBeenCalled() + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: 'pty-1', data: query }) + }) + it('coalesces background PTY output before notifying the client', async () => { let dataCallback: ((data: string) => void) | undefined mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index fb0d38bf53c..d06695342d0 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -39,6 +39,12 @@ import { } from '../shared/git-credential-prompt-env' import { isTuiAgent } from '../shared/tui-agent-config' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' +import { + PTY_STARTUP_INGRESS_VERSION, + PtyStartupIngress, + parsePtyStartupIngressIntent, + type PtyIngressEmission +} from '../shared/pty-startup-ingress' function isMissingNodePtyNativeBinding(error: unknown): boolean { return ( @@ -78,10 +84,15 @@ type ManagedPty = { physicalExit?: PhysicalExitTracker forceKillSent?: boolean gracefulKillSent?: boolean + startupIngress?: PtyStartupIngress + startupIngressIntent?: ReturnType } type PendingPtyOutput = { data: string + rawLength?: number + transformed?: boolean + seq?: number } type ManagedStartupCommand = { @@ -471,6 +482,9 @@ export class PtyHandler { } private appendReplayBuffer(managed: ManagedPty, data: string): void { + if (data.length === 0) { + return + } managed.buffered += data if (managed.buffered.length > REPLAY_BUFFER_MAX) { managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) @@ -504,8 +518,7 @@ export class PtyHandler { if (startup.scanState) { const heldBytes = drainShellReadyHeldBytes(startup.scanState) if (heldBytes) { - this.appendReplayBuffer(managed, heldBytes) - this.enqueuePtyOutput(managed.id, heldBytes) + managed.startupIngress?.accept(heldBytes) } } const submit = process.platform === 'win32' ? '\r' : '\n' @@ -525,6 +538,22 @@ export class PtyHandler { private wireAndStore(managed: ManagedPty): void { managed.physicalExit = new PhysicalExitTracker() this.ptys.set(managed.id, managed) + const emitIngressData = (emission: PtyIngressEmission): void => { + const rawLength = emission.rawEndSeq - emission.rawStartSeq + this.appendReplayBuffer(managed, emission.data) + this.enqueuePtyOutput( + managed.id, + emission.data, + emission.transformed || rawLength !== emission.data.length + ? { rawLength, seq: emission.rawEndSeq, transformed: true } + : {} + ) + } + managed.startupIngress ??= new PtyStartupIngress({ + ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), + write: (data) => managed.pty.write(data), + onEmission: emitIngressData + }) managed.pty.onData((data: string) => { const startup = managed.startupCommand if (startup?.waitForShellReady && startup.scanState && !startup.delivered) { @@ -534,8 +563,7 @@ export class PtyHandler { this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS) } } - this.appendReplayBuffer(managed, data) - this.enqueuePtyOutput(managed.id, data) + managed.startupIngress?.accept(data) }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { managed.physicalExit?.markExited() @@ -560,6 +588,7 @@ export class PtyHandler { managed.killTimer = undefined } this.clearStartupCommandTimer(managed) + this.releaseRelayIngress(managed) this.flushPtyOutput(managed.id) this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) this.notifyExitListener(managed) @@ -572,6 +601,17 @@ export class PtyHandler { }) } + private releaseRelayIngress(managed: ManagedPty): void { + const startupCommand = managed.startupCommand + const scanState = startupCommand?.scanState + if (scanState) { + const held = drainShellReadyHeldBytes(scanState) + startupCommand.scanState = null + managed.startupIngress?.accept(held) + } + managed.startupIngress?.drainAndClose() + } + private notifyExitListener(managed: ManagedPty): void { if (managed.exitListenerNotified) { return @@ -605,6 +645,9 @@ export class PtyHandler { this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p)) this.dispatcher.onRequest('pty.revive', (p) => this.revive(p)) this.dispatcher.onRequest('pty.getProfiles', async () => listShellProfiles()) + this.dispatcher.onRequest('pty.closeStartupQueryAuthority', (p) => + this.closeStartupQueryAuthority(p) + ) this.dispatcher.onNotification('pty.data', (p) => this.writeData(p)) this.dispatcher.onNotification('pty.resize', (p) => this.resize(p)) @@ -620,6 +663,17 @@ export class PtyHandler { return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') } + private async closeStartupQueryAuthority( + params: Record + ): Promise<{ appliedSeq: number }> { + const id = params.id as string + const managed = this.ptys.get(id) + if (!managed || managed.disposed) { + throw new Error(`PTY "${id}" not found`) + } + return { appliedSeq: managed.startupIngress?.closeQueryAuthority() ?? 0 } + } + private shouldSendInteractiveOutputNow(id: string, data: string): boolean { const lastInputAt = this.lastInputAtByPty.get(id) const now = performance.now() @@ -640,15 +694,35 @@ export class PtyHandler { return true } - private enqueuePtyOutput(id: string, data: string): void { + private enqueuePtyOutput( + id: string, + data: string, + meta: { rawLength?: number; transformed?: boolean; seq?: number } = {} + ): void { const existing = this.pendingOutputByPty.get(id) - const pending = { data: (existing?.data ?? '') + data } + if (meta.transformed === true) { + // Why: transformed spans have no raw-to-clean slice mapping, so neither + // side of their boundary may be folded into the relay's output batch. + if (existing) { + this.flushPtyOutput(id) + } + this.dispatcher.notify('pty.data', { id, data, ...meta }) + return + } + const pending: PendingPtyOutput = { data: (existing?.data ?? '') + data } + if (existing?.rawLength !== undefined || meta.rawLength !== undefined) { + pending.rawLength = + (existing?.rawLength ?? existing?.data.length ?? 0) + (meta.rawLength ?? data.length) + } + if (meta.seq !== undefined) { + pending.seq = meta.seq + } if (this.shouldSendInteractiveOutputNow(id, pending.data)) { this.pendingOutputByPty.delete(id) this.clearOutputFlushTimerIfIdle() // Why: remote agent TUIs redraw around each keystroke. Background relay // batching should reduce SSH chatter, not add visible input echo delay. - this.dispatcher.notify('pty.data', { id, data: pending.data }) + this.dispatcher.notify('pty.data', { id, ...pending }) return } this.pendingOutputByPty.set(id, pending) @@ -670,12 +744,31 @@ export class PtyHandler { break } this.pendingOutputByPty.delete(id) - const chunk = pending.data.slice(0, PTY_OUTPUT_FLUSH_CHUNK_CHARS) - const remaining = pending.data.slice(PTY_OUTPUT_FLUSH_CHUNK_CHARS) + const chunk = pending.transformed + ? pending.data + : pending.data.slice(0, PTY_OUTPUT_FLUSH_CHUNK_CHARS) + const remaining = pending.transformed ? '' : pending.data.slice(PTY_OUTPUT_FLUSH_CHUNK_CHARS) if (remaining) { - this.pendingOutputByPty.set(id, { data: remaining }) + this.pendingOutputByPty.set(id, { + data: remaining, + ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }), + seq: pending.seq + }) } - this.dispatcher.notify('pty.data', { id, data: chunk }) + const chunkRawLength = pending.transformed + ? pending.rawLength + : pending.rawLength === undefined + ? undefined + : chunk.length + const chunkSeq = + pending.seq === undefined ? undefined : pending.seq - (pending.data.length - chunk.length) + this.dispatcher.notify('pty.data', { + id, + data: chunk, + ...(chunkSeq === undefined ? {} : { seq: chunkSeq }), + ...(chunkRawLength === undefined ? {} : { rawLength: chunkRawLength }), + ...(pending.transformed ? { transformed: true } : {}) + }) writes++ } if (this.pendingOutputByPty.size > 0 && writes > 0) { @@ -691,7 +784,7 @@ export class PtyHandler { return } this.pendingOutputByPty.delete(id) - this.dispatcher.notify('pty.data', { id, data: pending.data }) + this.dispatcher.notify('pty.data', { id, ...pending }) this.clearOutputFlushTimerIfIdle() } @@ -891,6 +984,12 @@ export class PtyHandler { tabId: typeof params.tabId === 'string' ? params.tabId : tabId } const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined + const startupIngressIntent = + params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION + ? parsePtyStartupIngressIntent(params.startupIngress, { + allowWindowsEchoProjection: false + }) + : undefined const managed: ManagedPty = { id, pty: term, @@ -903,6 +1002,7 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ...(startupIngressIntent ? { startupIngressIntent } : {}), ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { @@ -958,6 +1058,8 @@ export class PtyHandler { // grace window already takes. if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { managed.physicalExit?.markExited() + this.releaseRelayIngress(managed) + this.flushPtyOutput(id) this.notifyExitListener(managed) disposeManagedPty(managed) this.ptys.delete(id) @@ -984,6 +1086,8 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found (identity mismatch)`) } + managed.startupIngress?.snapshotBarrier() + // Replay buffered output. During pty.spawn({ sessionId }) the renderer has // not registered replay handlers yet, so return the bytes to the caller // instead of notifying them too early. @@ -1175,6 +1279,7 @@ export class PtyHandler { const id = params.id as string const managed = this.ptys.get(id) if (managed && !managed.disposed) { + managed.startupIngress?.snapshotBarrier() managed.pty.clear() } } @@ -1385,6 +1490,10 @@ export class PtyHandler { private async disposePtys(waitForPhysicalExit: boolean): Promise { this.cancelGraceTimer() await this.waitForPendingPtyCreations() + for (const managed of this.ptys.values()) { + this.releaseRelayIngress(managed) + this.flushPtyOutput(managed.id) + } if (this.outputFlushTimer !== null) { clearTimeout(this.outputFlushTimer) this.outputFlushTimer = null @@ -1414,6 +1523,7 @@ export class PtyHandler { managed.killTimer = undefined } this.clearStartupCommandTimer(managed) + this.releaseRelayIngress(managed) // Why: relay exit must retain the native owner until SIGKILL is accepted // (with one bounded retry) or onExit proves the process is already gone. await this.requestForceKillForRelayShutdown(managed) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 18066ae75d2..0c1259d665e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -7085,6 +7085,7 @@ export function connectPanePty( return } if (!foreground && orderedRendererData.length === 0) { + recordRendererOrderedSeq(rendererMeta) schedulePendingStartupCommandDelivery() return } diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 2b8781a5221..efc103531ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -37,6 +37,7 @@ import { installTerminalFreezeReport } from './terminal-freeze-report' export type PtyDataMeta = { seq?: number rawLength?: number + transformed?: boolean background?: boolean /** Main dropped this PTY's buffered output at the pending cap; the pane * must repaint from the main-owned snapshot instead of the live stream. */ @@ -170,6 +171,7 @@ function handleDispatchedPtyData(payload: { data: string seq?: number rawLength?: number + transformed?: boolean background?: boolean droppedOutput?: boolean }): void { @@ -182,6 +184,10 @@ function handleDispatchedPtyData(payload: { meta ??= {} meta.rawLength = payload.rawLength } + if (payload.transformed === true) { + meta ??= {} + meta.transformed = true + } if (payload.background === true) { meta ??= {} meta.background = true diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts index e5ada3e6ca6..0c49f0a479f 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -105,6 +105,23 @@ class FakeMultiplexServer { this.send(TerminalStreamOpcode.Output, encodeTerminalStreamText(text), this.cursorUnits) } + outputSpan(data: string, rawLength: number): void { + this.cursorUnits += rawLength + this.send( + TerminalStreamOpcode.OutputSpan, + encodeTerminalStreamJson({ data, rawLength, transformed: true }), + this.cursorUnits + ) + } + + malformedOutputSpan(): void { + this.send( + TerminalStreamOpcode.OutputSpan, + encodeTerminalStreamJson({ data: 'framing must not render' }), + this.cursorUnits + ) + } + flushHeldManualSnapshot(): void { if (this.heldManualRequestId === null) { throw new Error('No manual snapshot is held') @@ -144,24 +161,29 @@ describe('remote terminal frame-drop resync', () => { async function subscribeClient(): Promise<{ data: string[] + metas: { seq?: number; rawLength?: number; transformed?: boolean }[] snapshots: string[] stream: RemoteRuntimeMultiplexedTerminal }> { const data: string[] = [] + const metas: { seq?: number; rawLength?: number; transformed?: boolean }[] = [] const snapshots: string[] = [] const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-1') const stream = await multiplexer.subscribeTerminal({ terminal: 'terminal-1', client: { id: 'desktop-1', type: 'desktop' }, callbacks: { - onData: (chunk) => data.push(chunk), + onData: (chunk, meta) => { + data.push(chunk) + metas.push(meta ?? {}) + }, onSnapshot: (chunk) => snapshots.push(chunk) } }) // Let the initial snapshot round-trip settle. await Promise.resolve() await Promise.resolve() - return { data, snapshots, stream } + return { data, metas, snapshots, stream } } it('detects a dropped Output frame via the seq gap and resyncs', async () => { @@ -197,6 +219,27 @@ describe('remote terminal frame-drop resync', () => { expect(snapshots).toEqual(['INITIAL']) }) + it('delivers an empty transformed span with its raw sequence metadata', async () => { + const { data, metas, snapshots } = await subscribeClient() + + server.outputSpan('', 9) + + expect(data).toEqual(['']) + expect(metas).toEqual([{ seq: 9, rawLength: 9, transformed: true }]) + expect(snapshots).toEqual(['INITIAL']) + }) + + it('requests an authoritative resync instead of rendering malformed span JSON', async () => { + const { data, snapshots } = await subscribeClient() + + server.malformedOutputSpan() + await Promise.resolve() + await Promise.resolve() + + expect(data).toEqual([]) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) + }) + it('uses UTF-16 sequence units when detecting gaps in multibyte output', async () => { const { data, snapshots } = await subscribeClient() diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 7adf6b5dcf2..f4990423409 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -37,7 +37,7 @@ type TerminalMultiplexEvent = | { type: string; streamId?: number; [key: string]: unknown } export type RemoteRuntimeMultiplexedTerminalCallbacks = { - onData: (data: string, meta?: { seq?: number; rawLength?: number }) => void + onData: (data: string, meta?: { seq?: number; rawLength?: number; transformed?: boolean }) => void onSnapshot: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void onSubscribed?: () => void onEnd?: () => void @@ -437,10 +437,42 @@ class RemoteRuntimeTerminalMultiplexer { if (!stream) { return } - if (frame.opcode === TerminalStreamOpcode.Output) { - const data = decodeTerminalStreamText(frame.payload) + if ( + frame.opcode === TerminalStreamOpcode.Output || + frame.opcode === TerminalStreamOpcode.OutputSpan + ) { + const span = + frame.opcode === TerminalStreamOpcode.OutputSpan + ? decodeTerminalStreamJson<{ + data?: unknown + rawLength?: unknown + transformed?: unknown + }>(frame.payload) + : null + const validSpan = + frame.opcode !== TerminalStreamOpcode.OutputSpan || + (typeof span?.data === 'string' && + typeof span.rawLength === 'number' && + Number.isSafeInteger(span.rawLength) && + span.rawLength >= 0 && + span.transformed === true) + const data = + frame.opcode === TerminalStreamOpcode.OutputSpan + ? validSpan + ? (span!.data as string) + : '' + : decodeTerminalStreamText(frame.payload) try { - const rawLength = data.length + if (!validSpan) { + // Why: rendering malformed span JSON would expose protocol framing + // as terminal text and lose its raw sequence accounting. + this.requestResyncSnapshot(stream) + return + } + const rawLength = + frame.opcode === TerminalStreamOpcode.OutputSpan && typeof span?.rawLength === 'number' + ? span.rawLength + : data.length // Why: a resync snapshot is authoritative; discard live output while // it is in flight, but still return transport credit in finally. if (stream.resyncInFlight) { @@ -454,7 +486,11 @@ class RemoteRuntimeTerminalMultiplexer { if (typeof seq === 'number') { stream.expectedSeq = seq } - stream.callbacks.onData(data, { seq, rawLength }) + stream.callbacks.onData(data, { + seq, + rawLength, + ...(frame.opcode === TerminalStreamOpcode.OutputSpan ? { transformed: true } : {}) + }) } finally { if (stream.acknowledgeOutput) { if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { diff --git a/src/shared/pty-startup-ingress-intent.ts b/src/shared/pty-startup-ingress-intent.ts new file mode 100644 index 00000000000..8b58c1183b6 --- /dev/null +++ b/src/shared/pty-startup-ingress-intent.ts @@ -0,0 +1,52 @@ +import { + terminalOscColorQueryReplies, + type TerminalOscColorQueryReplyColors +} from './terminal-osc-color-reply' + +export type PtyStartupIngressIntent = { + colors: TerminalOscColorQueryReplyColors + deadlineMs: number + echoProjection?: 'windows-conpty-esc-stripped' +} + +export const PTY_STARTUP_INGRESS_VERSION = 1 + +export function parsePtyStartupIngressIntent( + value: unknown, + options: { allowWindowsEchoProjection: boolean } +): PtyStartupIngressIntent | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const record = value as Record + const colors = record.colors + if (!colors || typeof colors !== 'object') { + return undefined + } + const colorRecord = colors as Record + const normalizedColors = { + ...(typeof colorRecord.foreground === 'string' ? { foreground: colorRecord.foreground } : {}), + ...(typeof colorRecord.background === 'string' ? { background: colorRecord.background } : {}) + } + if ( + !terminalOscColorQueryReplies(normalizedColors, [10, 11]) || + typeof record.deadlineMs !== 'number' || + !Number.isFinite(record.deadlineMs) || + record.deadlineMs < 0 || + record.deadlineMs > 30_000 + ) { + return undefined + } + const projection = record.echoProjection + if ( + projection !== undefined && + (projection !== 'windows-conpty-esc-stripped' || !options.allowWindowsEchoProjection) + ) { + return undefined + } + return { + colors: normalizedColors, + deadlineMs: record.deadlineMs, + ...(projection === 'windows-conpty-esc-stripped' ? { echoProjection: projection } : {}) + } +} diff --git a/src/shared/pty-startup-ingress.test.ts b/src/shared/pty-startup-ingress.test.ts new file mode 100644 index 00000000000..55268f04f7b --- /dev/null +++ b/src/shared/pty-startup-ingress.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + PtyStartupIngress, + parsePtyStartupIngressIntent, + type PtyIngressEmission +} from './pty-startup-ingress' + +const COLORS = { foreground: '#2e3434', background: '#ffffff' } + +function createHarness(options: { projection?: boolean; nested?: (data: string) => void } = {}) { + const emissions: PtyIngressEmission[] = [] + let ingress!: PtyStartupIngress + const writes: string[] = [] + ingress = new PtyStartupIngress({ + intent: { + colors: COLORS, + deadlineMs: 5_000, + ...(options.projection ? { echoProjection: 'windows-conpty-esc-stripped' as const } : {}) + }, + write: (data) => { + writes.push(data) + options.nested?.(data) + }, + onEmission: (emission) => emissions.push(emission) + }) + return { ingress, writes, emissions } +} + +function visible(emissions: readonly PtyIngressEmission[]): string { + return emissions.map((emission) => emission.data).join('') +} + +describe('PtyStartupIngress', () => { + afterEach(() => vi.useRealTimers()) + + it('validates intent bounds and rejects a Windows projection on isolated hosts', () => { + const intent = { + colors: COLORS, + deadlineMs: 5_000, + echoProjection: 'windows-conpty-esc-stripped' + } + expect(parsePtyStartupIngressIntent(intent, { allowWindowsEchoProjection: true })).toEqual( + intent + ) + expect(parsePtyStartupIngressIntent(intent, { allowWindowsEchoProjection: false })).toBe( + undefined + ) + expect( + parsePtyStartupIngressIntent( + { ...intent, deadlineMs: 30_001 }, + { + allowWindowsEchoProjection: true + } + ) + ).toBeUndefined() + }) + + it('recognizes BEL/ST queries at every split and emits canonical replies', () => { + const query = '\x1b]10;?\x07\x1b]11;?\x1b\\' + for (let split = 0; split <= query.length; split += 1) { + const { ingress, writes, emissions } = createHarness() + ingress.accept(query.slice(0, split)) + ingress.accept(query.slice(split)) + ingress.drainAndClose() + expect(visible(emissions), `split ${split}`).toBe('') + expect(writes, `split ${split}`).toEqual([ + '\x1b]10;rgb:2e2e/3434/3434\x1b\\', + '\x1b]11;rgb:ffff/ffff/ffff\x1b\\' + ]) + expect(emissions.reduce((sum, item) => sum + item.rawEndSeq - item.rawStartSeq, 0)).toBe( + query.length + ) + } + }) + + it('suppresses the first echo immediately and keeps a later exact collision', () => { + const { ingress, emissions } = createHarness({ projection: true }) + ingress.accept('\x1b]10;?\x07') + const projected = ']10;rgb:2e2e/3434/3434\\' + ingress.accept(projected) + ingress.accept(projected) + ingress.drainAndClose() + expect(visible(emissions)).toBe(projected) + }) + + it('matches each echo across every split without skipping an earlier FIFO candidate', () => { + const foregroundEcho = ']10;rgb:2e2e/3434/3434\\' + const backgroundEcho = ']11;rgb:ffff/ffff/ffff\\' + for (const projected of [foregroundEcho, backgroundEcho]) { + for (let split = 0; split <= projected.length; split += 1) { + const { ingress, emissions } = createHarness({ projection: true }) + ingress.accept(projected === foregroundEcho ? '\x1b]10;?\x07' : '\x1b]11;?\x1b\\') + ingress.accept(projected.slice(0, split)) + ingress.accept(projected.slice(split)) + ingress.drainAndClose() + expect(visible(emissions), `${projected.slice(0, 3)} split ${split}`).toBe('') + } + } + + const fifo = createHarness({ projection: true }) + fifo.ingress.accept('\x1b]10;?;?\x1b\\') + fifo.ingress.accept(backgroundEcho) + fifo.ingress.accept(backgroundEcho) + fifo.ingress.drainAndClose() + expect(visible(fifo.emissions)).toBe(backgroundEcho) + }) + + it('releases partial echo bytes on mismatch, timeout, and snapshot barrier', () => { + vi.useFakeTimers() + const mismatch = createHarness({ projection: true }) + mismatch.ingress.accept('\x1b]10;?\x07') + mismatch.ingress.accept(']10;rgb:2e2e/nope') + expect(visible(mismatch.emissions)).toBe(']10;rgb:2e2e/nope') + + const timeout = createHarness({ projection: true }) + timeout.ingress.accept('\x1b]10;?\x07') + timeout.ingress.accept(']10;rgb:2e2e/') + vi.advanceTimersByTime(5_000) + expect(visible(timeout.emissions)).toBe(']10;rgb:2e2e/') + + const snapshot = createHarness({ projection: true }) + snapshot.ingress.accept('\x1b]10;?\x07') + snapshot.ingress.accept(']10;rgb:2e2e/') + snapshot.ingress.snapshotBarrier() + expect(visible(snapshot.emissions)).toBe(']10;rgb:2e2e/') + + snapshot.ingress.accept('\x1b]11;?\x07') + expect(snapshot.writes.at(-1)).toBe('\x1b]11;rgb:ffff/ffff/ffff\x1b\\') + }) + + it('serializes a synchronous nested provider callback after the consumed query span', () => { + const emissions: PtyIngressEmission[] = [] + let ingress!: PtyStartupIngress + ingress = new PtyStartupIngress({ + intent: { colors: COLORS, deadlineMs: 5_000 }, + write: () => ingress.accept('nested'), + onEmission: (emission) => emissions.push(emission) + }) + ingress.accept('before\x1b]10;?\x07after') + ingress.drainAndClose() + expect(emissions.map(({ data, transformed }) => ({ data, transformed }))).toEqual([ + { data: 'before', transformed: false }, + { data: '', transformed: true }, + { data: 'after', transformed: false }, + { data: 'nested', transformed: false } + ]) + }) + + it('ignores callbacks after teardown without recreating the raw sequence domain', () => { + const { ingress, emissions } = createHarness({ projection: true }) + ingress.accept('\x1b]10;?\x07') + ingress.accept(']10;rgb:2e2e/') + const closedAt = ingress.drainAndClose() + ingress.accept('late') + expect(ingress.acceptedRawSequence).toBe(closedAt) + expect(visible(emissions)).toBe(']10;rgb:2e2e/') + }) +}) diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts new file mode 100644 index 00000000000..56042a6c52d --- /dev/null +++ b/src/shared/pty-startup-ingress.ts @@ -0,0 +1,340 @@ +import { + parseTerminalOscColorQuery, + terminalOscColorQueryReplies, + type TerminalOscColorQuerySlot +} from './terminal-osc-color-reply' +import type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' + +export { + PTY_STARTUP_INGRESS_VERSION, + parsePtyStartupIngressIntent +} from './pty-startup-ingress-intent' +export type { PtyStartupIngressIntent } from './pty-startup-ingress-intent' + +export type PtyIngressEmission = { + data: string + rawStartSeq: number + rawEndSeq: number + transformed: boolean +} + +type PtyIngressSourceChunk = { + data: string + rawStartSeq: number + rawEndSeq: number +} + +type PendingOperation = + | { kind: 'data'; chunk: PtyIngressSourceChunk } + | { kind: 'close-query' } + | { kind: 'snapshot' } + | { kind: 'teardown' } + | { kind: 'expire' } + +type PendingSpan = PtyIngressSourceChunk + +export type PtyStartupIngressOptions = { + intent?: PtyStartupIngressIntent + write: (data: string) => void + onEmission: (emission: PtyIngressEmission) => void +} + +const MAX_QUERY_CANDIDATE_CHARS = 64 + +function spanSlice(span: PendingSpan, start: number, end = span.data.length): PendingSpan { + return { + data: span.data.slice(start, end), + rawStartSeq: span.rawStartSeq + start, + rawEndSeq: span.rawStartSeq + end + } +} + +function combineSpans(first: PendingSpan | null, second: PendingSpan): PendingSpan { + if (!first) { + return second + } + return { + data: first.data + second.data, + rawStartSeq: first.rawStartSeq, + rawEndSeq: second.rawEndSeq + } +} + +function projectedWindowsConptyReply(reply: string): string { + // Why: the native provider harness observes ConPTY's cooked echo with ESC removed. + return reply.replaceAll('\x1b', '') +} + +/** + * Serialized source-side startup classifier. Its raw sequence begins after + * shell-ready preprocessing and every accepted range is emitted exactly once. + */ +export class PtyStartupIngress { + private readonly intent: PtyStartupIngressIntent | undefined + private readonly writeProvider: (data: string) => void + private readonly onEmission: (emission: PtyIngressEmission) => void + private readonly operations: PendingOperation[] = [] + private readonly answeredSlots = new Set() + private readonly expectedEchoes: string[] = [] + private processing = false + private closed = false + private queryOpen: boolean + private rawHighWater = 0 + private queryPending: PendingSpan | null = null + private echoPending: PendingSpan | null = null + private deadlineTimer: ReturnType | null = null + + constructor(options: PtyStartupIngressOptions) { + this.intent = options.intent + this.writeProvider = options.write + this.onEmission = options.onEmission + this.queryOpen = options.intent !== undefined + if (options.intent) { + this.deadlineTimer = setTimeout( + () => this.enqueue({ kind: 'expire' }), + Math.max(0, options.intent.deadlineMs) + ) + this.deadlineTimer.unref?.() + } + } + + get acceptedRawSequence(): number { + return this.rawHighWater + } + + accept(data: string): void { + if (this.closed || data.length === 0) { + return + } + const rawStartSeq = this.rawHighWater + this.rawHighWater += data.length + this.enqueue({ + kind: 'data', + chunk: { data, rawStartSeq, rawEndSeq: this.rawHighWater } + }) + } + + closeQueryAuthority(): number { + this.enqueue({ kind: 'close-query' }) + return this.rawHighWater + } + + snapshotBarrier(): number { + this.enqueue({ kind: 'snapshot' }) + return this.rawHighWater + } + + drainAndClose(): number { + this.enqueue({ kind: 'teardown' }) + return this.rawHighWater + } + + private enqueue(operation: PendingOperation): void { + if (this.closed) { + return + } + this.operations.push(operation) + if (this.processing) { + return + } + this.processing = true + try { + let next: PendingOperation | undefined + while ((next = this.operations.shift())) { + this.applyOperation(next) + } + } finally { + this.processing = false + } + } + + private applyOperation(operation: PendingOperation): void { + switch (operation.kind) { + case 'data': + this.processEchoSpan(operation.chunk) + return + case 'close-query': + this.queryOpen = false + this.releaseQueryPending() + return + case 'expire': + this.queryOpen = false + this.releaseAllPending() + this.expectedEchoes.length = 0 + this.clearDeadline() + return + case 'snapshot': + this.releaseSnapshotPending() + return + case 'teardown': + this.queryOpen = false + this.releaseAllPending() + this.expectedEchoes.length = 0 + this.clearDeadline() + this.closed = true + } + } + + private processEchoSpan(span: PendingSpan): void { + let input = combineSpans(this.echoPending, span) + this.echoPending = null + + while (this.expectedEchoes.length > 0) { + const expected = this.expectedEchoes[0] + const compared = Math.min(input.data.length, expected.length) + let matching = 0 + while (matching < compared && input.data[matching] === expected[matching]) { + matching += 1 + } + if (matching < compared) { + this.expectedEchoes.shift() + this.processQuerySpan(input) + return + } + if (input.data.length < expected.length) { + this.echoPending = input + return + } + + this.expectedEchoes.shift() + this.emit(spanSlice(input, 0, expected.length), true, '') + input = spanSlice(input, expected.length) + if (input.data.length === 0) { + return + } + } + + this.processQuerySpan(input) + } + + private processQuerySpan(span: PendingSpan): void { + const input = combineSpans(this.queryPending, span) + this.queryPending = null + if (!this.queryOpen || !this.intent) { + this.emit(input, false) + return + } + + let offset = 0 + while (offset < input.data.length) { + const candidateIndex = input.data.indexOf('\x1b', offset) + if (candidateIndex === -1) { + this.emit(spanSlice(input, offset), false) + return + } + if (candidateIndex > offset) { + this.emit(spanSlice(input, offset, candidateIndex), false) + } + const query = parseTerminalOscColorQuery(input.data, candidateIndex) + if (query.kind === 'none') { + this.emit(spanSlice(input, candidateIndex, candidateIndex + 1), false) + offset = candidateIndex + 1 + continue + } + if (query.kind === 'partial') { + const candidate = spanSlice(input, candidateIndex) + if (candidate.data.length <= MAX_QUERY_CANDIDATE_CHARS) { + this.queryPending = candidate + } else { + this.emit(candidate, false) + } + return + } + + const querySpan = spanSlice(input, candidateIndex, query.endIndex) + if (!this.answerQuery(query.slots)) { + this.emit(querySpan, false) + } else { + this.emit(querySpan, true, '') + } + offset = query.endIndex + } + } + + private answerQuery(slots: readonly TerminalOscColorQuerySlot[]): boolean { + if (slots.some((slot) => this.answeredSlots.has(slot)) || !this.intent) { + return false + } + const replies = terminalOscColorQueryReplies(this.intent.colors, slots) + if (!replies) { + return false + } + + let wroteAny = false + for (const [index, reply] of replies.entries()) { + const slot = slots[index] + if (slot === undefined) { + return wroteAny + } + this.answeredSlots.add(slot) + const projected = + this.intent.echoProjection === 'windows-conpty-esc-stripped' + ? projectedWindowsConptyReply(reply) + : null + if (projected) { + // Why: register before write because node-pty can synchronously re-enter onData. + this.expectedEchoes.push(projected) + } + try { + this.writeProvider(reply) + wroteAny = true + } catch { + this.answeredSlots.delete(slot) + if (projected) { + this.expectedEchoes.pop() + } + return wroteAny + } + } + + if (this.answeredSlots.has(10) && this.answeredSlots.has(11)) { + this.queryOpen = false + } + return wroteAny + } + + private releaseQueryPending(): void { + if (!this.queryPending) { + return + } + const pending = this.queryPending + this.queryPending = null + this.emit(pending, false) + } + + private releaseAllPending(): void { + const pending = this.echoPending ?? this.queryPending + this.echoPending = null + this.queryPending = null + if (pending) { + this.emit(pending, false) + } + } + + private releaseSnapshotPending(): void { + if (this.echoPending) { + const pending = this.echoPending + this.echoPending = null + this.expectedEchoes.shift() + this.emit(pending, false) + } + this.releaseQueryPending() + } + + private emit(span: PendingSpan, transformed: boolean, data = span.data): void { + this.onEmission({ + data, + rawStartSeq: span.rawStartSeq, + rawEndSeq: span.rawEndSeq, + transformed + }) + } + + private clearDeadline(): void { + if (!this.deadlineTimer) { + return + } + clearTimeout(this.deadlineTimer) + this.deadlineTimer = null + } +} diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 02314fb39e4..bcfa3b46d9c 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -20,7 +20,8 @@ export enum TerminalStreamOpcode { Ack = 13, // Why 14: Ack already occupies 13 on current clients; older runtimes ignore // this opcode and still receive the compatibility Resize frame behind it. - ClaimViewport = 14 + ClaimViewport = 14, + OutputSpan = 15 } export type TerminalStreamFrame = { @@ -102,6 +103,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.SnapshotRequest || value === TerminalStreamOpcode.Metadata || value === TerminalStreamOpcode.Ack || - value === TerminalStreamOpcode.ClaimViewport + value === TerminalStreamOpcode.ClaimViewport || + value === TerminalStreamOpcode.OutputSpan ) } From 7df56adaf1b4b5a062b8f671797d5eb03beae931 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:16:33 -0700 Subject: [PATCH 04/35] fix(ui): restore spaces around orca.yaml in trust dialog (#9511) --- .../sidebar/OrcaYamlTrustDialog.test.tsx | 98 +++++++++++++++++++ .../sidebar/OrcaYamlTrustDialog.tsx | 9 +- src/renderer/src/i18n/locales/es.json | 4 +- 3 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx diff --git a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx new file mode 100644 index 00000000000..65f937da42b --- /dev/null +++ b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx @@ -0,0 +1,98 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ButtonHTMLAttributes, ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + state: { + activeModal: 'confirm-orca-yaml-hooks' as string | null, + modalData: {} as Record, + closeModal: vi.fn(), + markOrcaHookScriptConfirmed: vi.fn(), + markOrcaHookRepoAlwaysTrusted: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), + { + getState: () => mocks.state + } + ) +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + ...props + }: ButtonHTMLAttributes & { children: ReactNode }) => ( + + ) +})) + +// Why: fall back to English defaults so this test doesn't depend on locale files +// being loaded; the bug is missing JSX whitespace around those fragments. +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +function decodeHtml(html: string): string { + return html + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') +} + +describe('OrcaYamlTrustDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.state.activeModal = 'confirm-orca-yaml-hooks' + mocks.state.modalData = { + repoId: 'repo-1', + repoName: 'orca', + scriptKind: 'setup', + scriptContent: 'node config/scripts/run-internal-dev-setup.mjs\npnpm install', + contentHash: 'hash-1', + previouslyApproved: false + } + }) + + it('keeps spaces around orca.yaml and the repo name in the first-run copy', async () => { + const { default: OrcaYamlTrustDialog } = await import('./OrcaYamlTrustDialog') + const text = decodeHtml(renderToStaticMarkup()).replace(/<[^>]+>/g, '') + + expect(text).toContain("This repository's orca.yaml runs on your machine") + expect(text).toContain('Only run if you trust orca.') + expect(text).toContain('Always trust orca.yaml in orca') + expect(text).not.toContain("repository'sorca.yaml") + expect(text).not.toContain('trustorca') + expect(text).not.toContain('trustorca.yaml') + expect(text).not.toContain('inorca') + }) + + it('keeps spaces around orca.yaml when the script changed since last approval', async () => { + mocks.state.modalData = { + ...mocks.state.modalData, + previouslyApproved: true + } + const { default: OrcaYamlTrustDialog } = await import('./OrcaYamlTrustDialog') + const text = decodeHtml(renderToStaticMarkup()).replace(/<[^>]+>/g, '') + + expect(text).toContain('orca.yaml changed since you last approved') + expect(text).toContain('Always trust orca.yaml in orca') + expect(text).not.toContain('Always trustorca.yaml') + expect(text).not.toContain('inorca') + }) +}) diff --git a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx index 63489c8b282..60ea9bcf90f 100644 --- a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx +++ b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx @@ -136,7 +136,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { {translate( 'auto.components.sidebar.OrcaYamlTrustDialog.aa3ffb33fb', "This repository's" - )} + )}{' '} {translate('auto.components.sidebar.OrcaYamlTrustDialog.79afc6772b', 'orca.yaml')} {' '} @@ -148,7 +148,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { {translate( 'auto.components.sidebar.OrcaYamlTrustDialog.bf800b7e04', '. Only run if you trust' - )} + )}{' '} {repoName}. )} @@ -190,12 +190,11 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { onChange={(event) => setAlwaysTrust(event.target.checked)} /> - {translate('auto.components.sidebar.OrcaYamlTrustDialog.531689199b', 'Always trust')} + {translate('auto.components.sidebar.OrcaYamlTrustDialog.531689199b', 'Always trust')}{' '} {translate('auto.components.sidebar.OrcaYamlTrustDialog.79afc6772b', 'orca.yaml')} {' '} - {translate('auto.components.sidebar.OrcaYamlTrustDialog.c494b3ccb1', 'in')} - {repoName} + {translate('auto.components.sidebar.OrcaYamlTrustDialog.c494b3ccb1', 'in')} {repoName} diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1565076a578..64f47a9c258 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3903,9 +3903,9 @@ "c494b3ccb1": "en", "79afc6772b": "orca.yaml", "531689199b": "Confiar siempre", - "bf800b7e04": ". Ejecútalo solo si confías en ", + "bf800b7e04": ". Ejecútalo solo si confías en", "831f2cd9f0": "se ejecuta en tu máquina", - "aa3ffb33fb": "El archivo ", + "aa3ffb33fb": "El archivo", "c55beddbf8": "cambió desde la última vez que lo aprobaste. Revísalo de nuevo antes de que se ejecute", "95bf974a1a": "Script {{value0}}", "9e52effffd": "Nuevo script {{value0}}", From 0d97653e3928f511cc28cb7a446c1985126e11fd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:24:24 -0700 Subject: [PATCH 05/35] fix(ui): restore missing spaces in i18n JSX fragment copy (#9513) --- .../src/components/GitHubItemDialog.tsx | 5 +- .../src/components/GitLabItemDialog.tsx | 4 +- .../src/components/PullRequestPage.tsx | 5 +- src/renderer/src/components/TaskPage.tsx | 2 +- src/renderer/src/components/UpdateCard.tsx | 3 +- .../automations/AutomationsPage.tsx | 2 +- .../ExternalAutomationRunTable.tsx | 2 +- .../ContextualTourProgressDots.tsx | 2 +- .../components/editor/CombinedDiffViewer.tsx | 2 +- .../components/editor/ConflictComponents.tsx | 6 +- .../src/components/editor/IpynbViewer.tsx | 2 +- .../src/components/editor/MermaidBlock.tsx | 3 +- .../editor/RichMarkdownDocLinkMenu.tsx | 4 +- .../ReviewNotesAnimatedVisual.tsx | 2 +- .../ReviewPRViewAnimatedVisual.tsx | 2 +- .../feature-wall/ReviewShipAnimatedVisual.tsx | 2 +- .../feature-wall/review-notes-diff-rows.tsx | 2 +- .../components/github/PRFilterSections.tsx | 3 +- .../github/github-rate-limit-display.tsx | 4 +- .../gitlab/gitlab-rate-limit-display.tsx | 2 +- .../src/components/linear-priority-icon.tsx | 9 +- .../right-sidebar/FileExplorerTreeStatus.tsx | 2 +- .../SourceControlAgentActionDialogForm.tsx | 2 +- .../right-sidebar/checks-panel-content.tsx | 4 +- .../src/components/settings/AccountsPane.tsx | 4 +- .../AutoRenameBranchFromWorkSetting.tsx | 8 +- .../src/components/settings/BaseRefPicker.tsx | 4 +- .../settings/BrowserSessionCookiesSection.tsx | 2 +- .../src/components/settings/CliSection.tsx | 2 +- .../settings/CommitMessageAiPane.tsx | 2 +- .../settings/GeneralEditorSettingsSection.tsx | 2 +- .../settings/GeneralUpdateSettingsSection.tsx | 4 +- .../src/components/settings/GitPane.tsx | 9 +- .../settings/ManageSessionKillDialog.tsx | 2 +- .../settings/NotificationSoundSection.tsx | 2 +- .../settings/RepositoryHooksSection.tsx | 6 +- .../components/settings/RepositoryPane.tsx | 2 +- ...orySourceControlAiHostedReviewDefaults.tsx | 2 +- .../settings/RuntimeEnvironmentsPane.tsx | 2 +- .../settings/SshPassphraseDialog.tsx | 4 +- .../settings/WslCliRegistration.tsx | 2 +- .../sidebar/DeleteWorktreeWarningPanels.tsx | 4 +- .../sidebar/SetupScriptPromptCardViews.tsx | 6 +- .../src/components/stats/ShareUsageCard.tsx | 2 +- .../components/stats/UsageOverviewPane.tsx | 2 +- .../src/lib/i18n-jsx-spacing-guard.test.ts | 140 ++++++++++++++++++ 46 files changed, 212 insertions(+), 77 deletions(-) create mode 100644 src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 6f6b7fb2424..5d98a84f0f1 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -4935,13 +4935,12 @@ function ChecksTab({ {startedAt && ( - {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')} - {startedAt} + {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')} {startedAt} )} {completedAt && ( - {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')} + {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')}{' '} {completedAt} )} diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index b71386b4c34..827d86ad2d8 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -1034,7 +1034,7 @@ export default function GitLabItemDialog({ {item.author ? ( - {translate('auto.components.GitLabItemDialog.9bfb4a24d7', 'by')} + {translate('auto.components.GitLabItemDialog.9bfb4a24d7', 'by')}{' '} {item.author} ) : null} @@ -1521,7 +1521,7 @@ export default function GitLabItemDialog({ {translate( 'auto.components.GitLabItemDialog.a7eb4f4916', 'from' - )} + )}{' '} {file.oldPath} ) : null} diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index b53ba354618..8cd3f872974 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -5030,13 +5030,12 @@ function ChecksTab({ {startedAt && ( - {translate('auto.components.PullRequestPage.76551b1161', 'Started')} - {startedAt} + {translate('auto.components.PullRequestPage.76551b1161', 'Started')} {startedAt} )} {completedAt && ( - {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')} + {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')}{' '} {completedAt} )} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 51bbc201879..727c0a44970 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -9323,7 +9323,7 @@ export default function TaskPage(): React.JSX.Element { // Why: per-repo partial-failure signal — distinct from a hard // IPC reject (tasksError). The two are mutually exclusive.
- {failedCount} {translate('auto.components.TaskPage.7762f4b03a', 'of')} + {failedCount} {translate('auto.components.TaskPage.7762f4b03a', 'of')}{' '} {selectedRepos.length}{' '} {translate('auto.components.TaskPage.d1766fd62d', 'projects failed to load')}
diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 9010a740e10..c080b518f64 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -887,8 +887,7 @@ function DownloadingContent({
{release ? (

- {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} - {release.title} + {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title}

) : (

diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 98a78ef9845..538cec979e3 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -2877,7 +2877,7 @@ export default function AutomationsPage(): React.JSX.Element { )} - {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')} + {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} {selectedRuns.length} diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx index aa8ff9666c7..f6e5f693371 100644 --- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx @@ -288,7 +288,7 @@ export function ExternalAutomationRunTable({ {pageStart}-{pageEnd}{' '} - {translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')} + {translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')}{' '} {totalCount}

diff --git a/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx b/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx index b52cbb6fdf4..bb7fde46fe1 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx @@ -46,7 +46,7 @@ export function ContextualTourProgressDots({ {current}{' '} - {translate('auto.components.contextual.tours.ContextualTourProgressDots.7734cb8ad3', 'of')} + {translate('auto.components.contextual.tours.ContextualTourProgressDots.7734cb8ad3', 'of')}{' '} {total} diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 9ae116de477..7622980a231 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -2022,7 +2022,7 @@ export default function CombinedDiffViewer({ {translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')} - {translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')} + {translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')}{' '} {diffCommentCount}{' '} {diffCommentCount === 1 ? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note') diff --git a/src/renderer/src/components/editor/ConflictComponents.tsx b/src/renderer/src/components/editor/ConflictComponents.tsx index 47c74ae88db..32159e23f00 100644 --- a/src/renderer/src/components/editor/ConflictComponents.tsx +++ b/src/renderer/src/components/editor/ConflictComponents.tsx @@ -101,7 +101,7 @@ export function ConflictBanner({ )} {label}{' '} - {translate('auto.components.editor.ConflictComponents.55d61a0ccd', 'conflict ·')} + {translate('auto.components.editor.ConflictComponents.55d61a0ccd', 'conflict ·')}{' '} {CONFLICT_KIND_LABELS[conflict.conflictKind]} {conflictNavigation && conflictNavigation.total > 0 && ( @@ -171,7 +171,7 @@ export function ConflictBanner({ )} {entry?.oldPath && (
- {translate('auto.components.editor.ConflictComponents.d5edd81755', 'Renamed from')} + {translate('auto.components.editor.ConflictComponents.d5edd81755', 'Renamed from')}{' '} {entry.oldPath}
)} @@ -334,7 +334,7 @@ export function ConflictReviewPanel({ {translate( 'auto.components.editor.ConflictComponents.a1ce36f77d', 'Snapshot captured at' - )} + )}{' '} {snapshotTime}. diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index fc19ca04175..ecb20f2ff63 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -899,7 +899,7 @@ export default function IpynbViewer({ {translate('auto.components.editor.IpynbViewer.329764e9fc', 'BETA')} - {translate('auto.components.editor.IpynbViewer.8c3b21369a', 'nbformat')} + {translate('auto.components.editor.IpynbViewer.8c3b21369a', 'nbformat')}{' '} {notebook.nbformat} diff --git a/src/renderer/src/components/editor/MermaidBlock.tsx b/src/renderer/src/components/editor/MermaidBlock.tsx index 250a0c32161..f25de3983ff 100644 --- a/src/renderer/src/components/editor/MermaidBlock.tsx +++ b/src/renderer/src/components/editor/MermaidBlock.tsx @@ -103,8 +103,7 @@ export default function MermaidBlock({ return (
- {translate('auto.components.editor.MermaidBlock.dcc132e691', 'Diagram error:')} - {error} + {translate('auto.components.editor.MermaidBlock.dcc132e691', 'Diagram error:')} {error}
           {content}
diff --git a/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx b/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
index d339f1e3da6..dafc70c658b 100644
--- a/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
@@ -68,9 +68,9 @@ export function RichMarkdownDocLinkMenu({
       )}
       {overflow ? (
         
- {translate('auto.components.editor.RichMarkdownDocLinkMenu.2aaf7d9678', 'Showing')} + {translate('auto.components.editor.RichMarkdownDocLinkMenu.2aaf7d9678', 'Showing')}{' '} {rows.length}{' '} - {translate('auto.components.editor.RichMarkdownDocLinkMenu.90c5f0e1e4', 'of')} + {translate('auto.components.editor.RichMarkdownDocLinkMenu.90c5f0e1e4', 'of')}{' '} {totalMatches}
) : null} diff --git a/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx index 24c8399761e..ba9684ba708 100644 --- a/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx @@ -381,7 +381,7 @@ export function ReviewNotesAnimatedVisual(props: { reducedMotion: boolean }): JS
- {translate('auto.components.feature.wall.ReviewNotesAnimatedVisual.a7a89d8f94', 'Line')} + {translate('auto.components.feature.wall.ReviewNotesAnimatedVisual.a7a89d8f94', 'Line')}{' '} ?
diff --git a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx index be3148a0e7c..461da2f21c6 100644 --- a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx @@ -272,7 +272,7 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J {translate( 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7', 'Add a coverage case for' - )} + )}{' '} {translate( 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec', diff --git a/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx index 0b2ef880ffa..548ac8334f2 100644 --- a/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx @@ -303,7 +303,7 @@ export function ReviewShipAnimatedVisual(props: { reducedMotion: boolean }): JSX {translate( 'auto.components.feature.wall.ReviewShipAnimatedVisual.e725000cd7', 'Changes' - )} + )}{' '} {SHIP_FILES.length} diff --git a/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx b/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx index c5c476a39d2..cbba8da521c 100644 --- a/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx +++ b/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx @@ -85,7 +85,7 @@ export function ReviewDiffRows(): JSX.Element { {translate( 'auto.components.feature.wall.review.notes.diff.rows.f621c734f8', 'Note · line' - )} + )}{' '} ?
diff --git a/src/renderer/src/components/github/PRFilterSections.tsx b/src/renderer/src/components/github/PRFilterSections.tsx index 59fc7634120..6f65eee8e55 100644 --- a/src/renderer/src/components/github/PRFilterSections.tsx +++ b/src/renderer/src/components/github/PRFilterSections.tsx @@ -208,8 +208,7 @@ export function SectionMenu({ return (
- {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} - {subject} + {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} {subject}
{rows.map((row) => (
diff --git a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx index 6bc87d967e8..a85c566b0d6 100644 --- a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx +++ b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx @@ -133,7 +133,7 @@ function GitLabRateLimitRows({ )} > {rest.remaining}{' '} - {translate('auto.components.gitlab.gitlab.rate.limit.display.ea8ad0bae8', 'of')} + {translate('auto.components.gitlab.gitlab.rate.limit.display.ea8ad0bae8', 'of')}{' '} {rest.limit}{' '} {translate( 'auto.components.gitlab.gitlab.rate.limit.display.3e2c982cfa', diff --git a/src/renderer/src/components/linear-priority-icon.tsx b/src/renderer/src/components/linear-priority-icon.tsx index ae9ebbb16a8..92f8bfaa210 100644 --- a/src/renderer/src/components/linear-priority-icon.tsx +++ b/src/renderer/src/components/linear-priority-icon.tsx @@ -54,8 +54,7 @@ export function LinearPriorityIcon({ > - {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} - {label} + {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label} ) @@ -72,8 +71,7 @@ export function LinearPriorityIcon({ className="size-3 rounded-full border border-muted-foreground/55" /> - {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} - {label} + {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label} ) @@ -110,8 +108,7 @@ export function LinearPriorityIcon({ })} - {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} - {label} + {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label} ) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx b/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx index 7f8d3824151..58fa294e5da 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx @@ -29,7 +29,7 @@ export function FileExplorerTreeStatus({ {translate( 'auto.components.right.sidebar.FileExplorerTreeStatus.c76693e456', 'Could not load files for this workspace:' - )} + )}{' '} {error}
) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index 4f2caf0133b..8f9fff3e657 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -378,7 +378,7 @@ export function SourceControlAgentActionDialogForm({ {translate( 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e', 'Launch:' - )} + )}{' '} {deliveryPlan.commandLabel}
{deliveryPlan.caveat}
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 84798c944f8..0dd54d7b7dd 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -735,7 +735,7 @@ function CheckRunDetails({ {translate( 'auto.components.right.sidebar.checks.panel.content.fd46a70f1a', 'Started' - )} + )}{' '} {startedAt} )} @@ -744,7 +744,7 @@ function CheckRunDetails({ {translate( 'auto.components.right.sidebar.checks.panel.content.00e1c1658a', 'Completed' - )} + )}{' '} {completedAt} )} diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 5268a890e36..4735f0b06c5 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1501,14 +1501,14 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.0023cc336e', 'Paste either the raw token value (e.g.' - )} + )}{' '} {translate('auto.components.settings.AccountsPane.922b51e02d', 'Fe26.2**…')} {translate( 'auto.components.settings.AccountsPane.338820326a', ') or the full cookie header (e.g.' - )} + )}{' '} {translate('auto.components.settings.AccountsPane.8951c5309f', 'auth=Fe26.2**…')} diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx index b8540cdefa9..c4d520cbdb7 100644 --- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx +++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx @@ -159,7 +159,7 @@ export function AutoRenameBranchFromWorkSetting({ {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.12ea4a408d', 'When an agent starts working in a new workspace, Orca renames its auto-generated branch (e.g.' - )} + )}{' '} {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.1626524572', @@ -223,7 +223,7 @@ export function AutoRenameBranchFromWorkSetting({ {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.9241b59bf5', 'Use' - )} + )}{' '} {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.c71770c455', @@ -261,7 +261,7 @@ export function AutoRenameBranchFromWorkSetting({ {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.56580dcf60', '. You can also reference' - )} + )}{' '} {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.2ee2779c05', @@ -281,7 +281,7 @@ export function AutoRenameBranchFromWorkSetting({ {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.5d569f5199', '. Orca generates only the final segment, like' - )} + )}{' '} {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.800edb1e54', diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index b0c0b6e72ef..8bb96a508cc 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -195,14 +195,14 @@ export function BaseRefPicker({ {translate( 'auto.components.settings.BaseRefPicker.a5c16712c1', 'Multiple remotes detected. Type a remote name (e.g.' - )} + )}{' '} {translate('auto.components.settings.BaseRefPicker.915ad97875', 'upstream')} {translate( 'auto.components.settings.BaseRefPicker.80f7c82303', ') or a full ref (e.g.' - )} + )}{' '} {translate('auto.components.settings.BaseRefPicker.b468f46726', 'upstream/main')} diff --git a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx index f1c39ca8fec..8fa1e6c9a60 100644 --- a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx +++ b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx @@ -72,7 +72,7 @@ export function BrowserSessionCookiesSection({ {translate( 'auto.components.settings.BrowserPane.cd47bc9622', 'Select a default profile for new browser tabs. Import cookies and switch profiles per-tab via the' - )} + )}{' '} ···{' '} {translate('auto.components.settings.BrowserPane.e4aaf8051b', 'toolbar menu.')}

diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 5f61d1e8ef3..6f6eab2ee94 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -317,7 +317,7 @@ export function CliSection({ {translate( 'auto.components.settings.CliSection.b0c310ab46', 'Existing launcher target:' - )} + )}{' '} {status.currentTarget}

) : null} diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 9b8ebc6d9ff..e24fc97ee40 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -273,7 +273,7 @@ export function CommitMessageAiPane({ {translate( 'auto.components.settings.CommitMessageAiPane.4f722a5f53', 'Used by commit-message, pull-request, and branch-name recipes that select Custom command. Use' - )} + )}{' '} {translate('auto.components.settings.CommitMessageAiPane.b8b6fd55b4', '{prompt}')} {' '} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 9cccdf166ee..2a524600b07 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -170,7 +170,7 @@ export function GeneralEditorSettingsSection({ {translate( 'auto.components.settings.GeneralEditorSettingsSection.8112cd6dcf', 'How long Orca waits after your last edit before saving automatically. First launch defaults to' - )} + )}{' '} {DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS}{' '} {translate('auto.components.settings.GeneralEditorSettingsSection.fc5c5306ff', 'ms.')}

diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 51668b1ac12..da1984cca0e 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -161,7 +161,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { {translate( 'auto.components.settings.GeneralUpdateSettingsSection.a6b37929dc', 'Version' - )} + )}{' '} {updateStatus.version}{' '} {translate( 'auto.components.settings.GeneralUpdateSettingsSection.8311da27ba', @@ -199,7 +199,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { {translate( 'auto.components.settings.GeneralUpdateSettingsSection.a6b37929dc', 'Version' - )} + )}{' '} {updateStatus.version}{' '} {translate( 'auto.components.settings.GeneralUpdateSettingsSection.d89806cc89', diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index 6d346fb1de6..bdfff4a6967 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -236,11 +236,14 @@ export function GitPane({ {translate( 'auto.components.settings.GitPane.976afc6b3e', 'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as' - )} + )}{' '} {translate('auto.components.settings.GitPane.ffba483bae', 'main')}{' '} - {translate('auto.components.settings.GitPane.5bf885be48', 'or')} + {translate('auto.components.settings.GitPane.5bf885be48', 'or')}{' '} {translate('auto.components.settings.GitPane.3ae3de8898', 'master')} - {translate('auto.components.settings.GitPane.db3a127eb1', '. This keeps commands like')} + {translate( + 'auto.components.settings.GitPane.db3a127eb1', + '. This keeps commands like' + )}{' '} {translate('auto.components.settings.GitPane.d072a12995', 'git diff main...HEAD')} {' '} diff --git a/src/renderer/src/components/settings/ManageSessionKillDialog.tsx b/src/renderer/src/components/settings/ManageSessionKillDialog.tsx index 56ddb9845ee..04c795f3748 100644 --- a/src/renderer/src/components/settings/ManageSessionKillDialog.tsx +++ b/src/renderer/src/components/settings/ManageSessionKillDialog.tsx @@ -66,7 +66,7 @@ export function ManageSessionKillDialog({ {translate( 'auto.components.settings.ManageSessionKillDialog.8401328fed', 'Force-quits' - )} + )}{' '} {session.sessionId} {translate( 'auto.components.settings.ManageSessionKillDialog.ad9832aa26', diff --git a/src/renderer/src/components/settings/NotificationSoundSection.tsx b/src/renderer/src/components/settings/NotificationSoundSection.tsx index 0d0b92279e7..da1fe4e11cc 100644 --- a/src/renderer/src/components/settings/NotificationSoundSection.tsx +++ b/src/renderer/src/components/settings/NotificationSoundSection.tsx @@ -159,7 +159,7 @@ export function NotificationSoundSection({ className="truncate font-mono text-[11px] text-muted-foreground" title={notificationSettings.customSoundPath} > - {translate('auto.components.settings.NotificationsPane.4aa5085cd7', 'Custom:')} + {translate('auto.components.settings.NotificationsPane.4aa5085cd7', 'Custom:')}{' '} {notificationSettings.customSoundPath}

) : null} diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index 1ee7cfe19f8..bec7ba52a28 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -441,7 +441,7 @@ function ExampleTemplateCard({ return (

- {translate('auto.components.settings.RepositoryHooksSection.175daba180', 'Example')} + {translate('auto.components.settings.RepositoryHooksSection.175daba180', 'Example')}{' '} {translate('auto.components.settings.RepositoryHooksSection.39da2ae12f', 'orca.yaml')} {' '} @@ -665,7 +665,7 @@ function ScriptEditor({ - {translate('auto.components.settings.RepositoryHooksSection.b113344b6a', 'Edit')} + {translate('auto.components.settings.RepositoryHooksSection.b113344b6a', 'Edit')}{' '} {translate( 'auto.components.settings.RepositoryHooksSection.39da2ae12f', @@ -1308,7 +1308,7 @@ export function RepositoryHooksSection({ {translate( 'auto.components.settings.RepositoryHooksSection.ac9038d2cc', 'When both' - )} + )}{' '} {translate( 'auto.components.settings.RepositoryHooksSection.39da2ae12f', diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index bb178d3c17f..a90c94e5ea8 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -226,7 +226,7 @@ export function RepositoryPane({ )}

- {translate('auto.components.settings.RepositoryPane.323debba71', 'Type:')} + {translate('auto.components.settings.RepositoryPane.323debba71', 'Type:')}{' '} {getRepoKindLabel(repo)}

{isFolder ? ( diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx index 7d620ee7ace..07156b38388 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx @@ -82,7 +82,7 @@ export function RepositorySourceControlAiHostedReviewDefaults({ {translate( 'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.a68849a859', 'Global default is' - )} + )}{' '} {inherited}. diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 116dc02c5be..af3e2be3ee0 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -813,7 +813,7 @@ export function RuntimeEnvironmentsPane({ className="h-8 min-w-0 font-mono text-xs" />

- {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')} + {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}{' '} {translate( 'auto.components.settings.RuntimeEnvironmentsPane.960e901ae4', diff --git a/src/renderer/src/components/settings/SshPassphraseDialog.tsx b/src/renderer/src/components/settings/SshPassphraseDialog.tsx index 7cfb4dbd0b8..56fd3c3d374 100644 --- a/src/renderer/src/components/settings/SshPassphraseDialog.tsx +++ b/src/renderer/src/components/settings/SshPassphraseDialog.tsx @@ -133,7 +133,7 @@ export function SshPassphraseDialog(): React.JSX.Element | null { {translate( 'auto.components.settings.SshPassphraseDialog.dbf9b6f2d0', 'Enter the password for' - )} + )}{' '} {label} ) : ( @@ -141,7 +141,7 @@ export function SshPassphraseDialog(): React.JSX.Element | null { {translate( 'auto.components.settings.SshPassphraseDialog.ce4fdf7914', 'Enter the passphrase for' - )} + )}{' '} {label} )} diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index 9d23dbeca93..7d01d96d58b 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -217,7 +217,7 @@ export function WslCliRegistration({ {translate( 'auto.components.settings.WslCliRegistration.1dbb0377d9', 'Existing launcher target:' - )} + )}{' '} {status.currentTarget}

) : null} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx index 89b22f6c841..b143148249c 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx @@ -21,7 +21,7 @@ export function DeleteWorktreeWarningPanels({ {translate( 'auto.components.sidebar.DeleteWorktreeWarningPanels.e3be9eba15', 'This is the' - )} + )}{' '} {translate( 'auto.components.sidebar.DeleteWorktreeWarningPanels.c4f96a6e18', @@ -32,7 +32,7 @@ export function DeleteWorktreeWarningPanels({ 'auto.components.sidebar.DeleteWorktreeWarningPanels.026738155a', '(the original clone directory).' )} - {mainWorktreeBlocker} + {mainWorktreeBlocker ? <> {mainWorktreeBlocker} : null}
diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx index 45f2947d3ed..25a960bff8c 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx @@ -71,7 +71,7 @@ export function DetectedSetupPreview({ {translate( 'auto.components.sidebar.SetupScriptPromptCardViews.d02e6a42b1', 'Detected from' - )} + )}{' '} {provenance}

) : null} @@ -157,7 +157,7 @@ export function SetupScriptPromptBody({ {translate( 'auto.components.sidebar.SetupScriptPromptCardViews.bb879db364', 'This repo ignores shared' - )} + )}{' '} {translate('auto.components.sidebar.SetupScriptPromptCardViews.8f6be51aa1', 'orca.yaml')} {' '} @@ -184,7 +184,7 @@ export function SetupScriptPromptBody({ {translate( 'auto.components.sidebar.SetupScriptPromptCardViews.b56d1322f7', 'Found a setup command in' - )} + )}{' '} {candidateSource} {translate( 'auto.components.sidebar.SetupScriptPromptCardViews.8349e3fa4c', diff --git a/src/renderer/src/components/stats/ShareUsageCard.tsx b/src/renderer/src/components/stats/ShareUsageCard.tsx index cb6eb5c92bf..dcd2f673cb2 100644 --- a/src/renderer/src/components/stats/ShareUsageCard.tsx +++ b/src/renderer/src/components/stats/ShareUsageCard.tsx @@ -233,7 +233,7 @@ function ChartHeader(props: {
{props.sessions}{' '} - {translate('auto.components.stats.ShareUsageCard.4a4c6c79a3', 'sessions ·')} + {translate('auto.components.stats.ShareUsageCard.4a4c6c79a3', 'sessions ·')}{' '} {props.turnsOrEvents.count} {props.turnsOrEvents.label}
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx index 1f2080c995d..03001c2bec9 100644 --- a/src/renderer/src/components/stats/UsageOverviewPane.tsx +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -253,7 +253,7 @@ export function UsageOverviewPane(): React.JSX.Element {

{overview.enabledProviderCount}{' '} - {translate('auto.components.stats.UsageOverviewPane.ecb0cd8a4c', 'enabled -')} + {translate('auto.components.stats.UsageOverviewPane.ecb0cd8a4c', 'enabled -')}{' '} {overview.dataProviderCount}{' '} {translate('auto.components.stats.UsageOverviewPane.444585cb41', 'with data')}

diff --git a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts new file mode 100644 index 00000000000..f2dde24571e --- /dev/null +++ b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard against the i18n fragment glue-word class of bugs: + * `translate('…', 'word')}` immediately followed by `` / `{value}` without + * an explicit `{' '}` space. Newline indentation alone is not reliable. + */ +const ROOT = path.resolve(__dirname, '..') + +type GuardCase = { + file: string + /** English fallback that must be followed by `{' '}` before the next token. */ + afterFallback: string + /** Human-readable description for assertion messages. */ + label: string +} + +const CASES: GuardCase[] = [ + { + file: 'components/sidebar/SetupScriptPromptCardViews.tsx', + afterFallback: 'This repo ignores shared', + label: 'shared orca.yaml' + }, + { + file: 'components/sidebar/SetupScriptPromptCardViews.tsx', + afterFallback: 'Detected from', + label: 'Detected from provenance' + }, + { + file: 'components/sidebar/SetupScriptPromptCardViews.tsx', + afterFallback: 'Found a setup command in', + label: 'Found a setup command in source' + }, + { + file: 'components/settings/RepositoryHooksSection.tsx', + afterFallback: 'Example', + label: 'Example orca.yaml template' + }, + { + file: 'components/settings/RepositoryHooksSection.tsx', + afterFallback: 'Edit', + label: 'Edit orca.yaml' + }, + { + file: 'components/settings/RepositoryHooksSection.tsx', + afterFallback: 'When both', + label: 'When both orca.yaml' + }, + { + file: 'components/settings/GitPane.tsx', + afterFallback: 'or', + label: 'main or master' + }, + { + file: 'components/settings/GitPane.tsx', + afterFallback: 'such as', + label: 'such as main' + }, + { + file: 'components/settings/RuntimeEnvironmentsPane.tsx', + afterFallback: 'Run', + label: 'Run orca serve' + }, + { + file: 'components/settings/AutoRenameBranchFromWorkSetting.tsx', + afterFallback: 'Use', + label: 'Use {basePrompt}' + }, + { + file: 'components/settings/CommitMessageAiPane.tsx', + afterFallback: 'Use', + label: 'Use {prompt}' + }, + { + file: 'components/editor/RichMarkdownDocLinkMenu.tsx', + afterFallback: 'Showing', + label: 'Showing N of M' + }, + { + file: 'components/editor/RichMarkdownDocLinkMenu.tsx', + afterFallback: 'of', + label: 'of totalMatches' + }, + { + file: 'components/contextual-tours/ContextualTourProgressDots.tsx', + afterFallback: 'of', + label: 'tour of total' + }, + { + file: 'components/github/PRFilterSections.tsx', + afterFallback: 'Filter', + label: 'Filter pull requests' + }, + { + file: 'components/editor/ConflictComponents.tsx', + afterFallback: 'Renamed from', + label: 'Renamed from path' + }, + { + file: 'components/settings/SshPassphraseDialog.tsx', + afterFallback: 'Enter the password for', + label: 'password for host' + }, + { + file: 'components/settings/ManageSessionKillDialog.tsx', + afterFallback: 'Force-quits', + label: 'Force-quits session id' + }, + { + file: 'components/sidebar/DeleteWorktreeWarningPanels.tsx', + afterFallback: 'This is the', + label: 'This is the main worktree' + } +] + +function hasExplicitSpaceAfterFallback(source: string, fallback: string): boolean { + // Match translate fallbacks that end with (or equal) the given phrase, then + // require an explicit {' '} after the closing )} so JSX whitespace is not + // left to indentation alone. + const escaped = fallback.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp( + String.raw`translate\(\s*'[^']+',\s*(?:'[^']*${escaped}'|"[^"]*${escaped}")\s*(?:,\s*\{[^}]*\})?\s*\)\s*\}\s*\{\s*['"]\s['"]\s*\}`, + 'm' + ) + return pattern.test(source) +} + +describe('i18n JSX fragment spacing guard', () => { + for (const testCase of CASES) { + it(`keeps an explicit space after "${testCase.label}" in ${testCase.file}`, () => { + const source = readFileSync(path.join(ROOT, testCase.file), 'utf8') + expect( + hasExplicitSpaceAfterFallback(source, testCase.afterFallback), + `Expected {' '} immediately after translate fallback "${testCase.afterFallback}" in ${testCase.file}` + ).toBe(true) + }) + } +}) From 74e2cb702ca34f171cdef140358dbf14a49d57f6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:30:01 -0700 Subject: [PATCH 06/35] perf(window): re-enable macOS main-window background throttling (#9395) Co-authored-by: Orca --- src/main/startup/configure-process.test.ts | 14 ++++ src/main/startup/configure-process.ts | 12 ++++ src/main/window/createMainWindow.test.ts | 74 ++++++++++++++++++++++ src/main/window/createMainWindow.ts | 65 +++++++++++++++---- 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 72a545a3988..08cd8139e93 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -297,6 +297,20 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu') }) + it('opts hidden pages out of intensive wake-up throttling', async () => { + const { app } = await import('electron') + const { enableMainProcessGpuFeatures } = await import('./configure-process') + + delete process.env.ORCA_E2E_USER_DATA_DIR + vi.mocked(app.commandLine.appendSwitch).mockClear() + enableMainProcessGpuFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'disable-features', + 'IntensiveWakeUpThrottling' + ) + }) + it('raises the WebGL context budget above the 16-context Blink default', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index e92c53e4b0d..e033e4e94de 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -344,4 +344,16 @@ export function enableMainProcessGpuFeatures(): void { if (features) { app.commandLine.appendSwitch('enable-features', features) } + + const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features') + // Why: with main-window background throttling on, Chromium's intensive mode + // clamps hidden-page timers to 1/min after 5 minutes, delaying agent-done and + // bell notifications by up to 60s. Keep the normal 1s hidden clamp (rAF and + // rendering still stop) but opt out of the 1/min tier. Callers skip this + // function under GPU fallback (win32-only today); if throttling ever extends + // to Windows, this opt-out must move out of the GPU-gated path. + const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures] + .filter(Boolean) + .join(',') + app.commandLine.appendSwitch('disable-features', disabledFeatures) } diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 8899ba81bbf..3b69b9c1253 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -299,6 +299,80 @@ describe('createMainWindow', () => { } }) + it('keeps main-window background throttling enabled while repainting macOS visibility transitions', () => { + vi.useFakeTimers() + const windowHandlers = new Map void)[]>() + let windowSize: [number, number] = [1200, 800] + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + isDestroyed: vi.fn(() => false), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + const handlers = windowHandlers.get(event) ?? [] + handlers.push(handler) + windowHandlers.set(event, handlers) + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => windowSize), + setSize: vi.fn((width: number, height: number) => { + windowSize = [width, height] + }), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + withPlatform('darwin', () => createMainWindow(null)) + + // Why: throttling-off pins visibilityState 'visible' and renders occluded + // windows at full rate; this guards against reintroducing it. + expect(webContents.setBackgroundThrottling).not.toHaveBeenCalledWith(false) + + expect(webContents.setBackgroundThrottling).toHaveBeenCalledWith(true) + expect(windowHandlers.get('restore')).toHaveLength(1) + expect(windowHandlers.get('show')).toHaveLength(1) + expect(windowHandlers.get('focus')).toHaveLength(1) + + windowHandlers.get('show')?.[0]?.() + windowHandlers.get('restore')?.[0]?.() + + expect(webContents.invalidate).toHaveBeenCalledTimes(2) + expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(1, 1201, 800) + expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(32) + expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(2, 1200, 800) + + vi.advanceTimersByTime(217) + expect(webContents.invalidate).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(1) + expect(webContents.invalidate).toHaveBeenCalledTimes(3) + + // Why: focus covers occlusion-uncover with invalidate only — no setSize + // jiggle that would resize terminals on every window focus. + const setSizeCalls = browserWindowInstance.setSize.mock.calls.length + windowHandlers.get('focus')?.[0]?.() + expect(webContents.invalidate).toHaveBeenCalledTimes(4) + expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(setSizeCalls) + }) + it('supports all minus key variants for terminal zoom out', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 52c84ec6048..b12583377b1 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -50,23 +50,65 @@ import { buildEditableContextMenuTemplate } from './editable-context-menu' import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' import { resolveWindowCloseAction } from './window-close-decision' +// Why: show/restore/resume can overlap before the size nudge resets; never +// capture the temporary width as the next repaint's baseline. +const activeRepaintJiggles = new WeakSet() + function forceRepaint(window: BrowserWindow): void { - if (window.isDestroyed()) { + // Why: webContents can be destroyed a beat before the BrowserWindow during + // close, and this runs from timers/focus events that can land in that gap. + if (window.isDestroyed() || window.webContents.isDestroyed()) { return } window.webContents.invalidate() - if (window.isMaximized() || window.isFullScreen()) { + if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) { return } + activeRepaintJiggles.add(window) const [width, height] = window.getSize() window.setSize(width + 1, height) setTimeout(() => { if (!window.isDestroyed()) { window.setSize(width, height) } + activeRepaintJiggles.delete(window) }, 32) } +function installMacosVisibilityRepaint(window: BrowserWindow): void { + let delayedRepaintTimer: ReturnType | null = null + const repaintAfterVisibilityTransition = (): void => { + forceRepaint(window) + if (delayedRepaintTimer) { + clearTimeout(delayedRepaintTimer) + } + // Why: macOS can finish restoring webview compositor layers after Electron's + // show/restore event, so a second paint catches late black-surface recovery. + delayedRepaintTimer = setTimeout(() => { + delayedRepaintTimer = null + forceRepaint(window) + }, 250) + } + const clearDelayedRepaint = (): void => { + if (delayedRepaintTimer) { + clearTimeout(delayedRepaintTimer) + delayedRepaintTimer = null + } + } + + window.on('restore', repaintAfterVisibilityTransition) + window.on('show', repaintAfterVisibilityTransition) + // Why: occlusion-uncover fires neither restore nor show; focus is the only + // signal. Invalidate only — the setSize jiggle would SIGWINCH every terminal + // on each Cmd+Tab. + window.on('focus', () => { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.invalidate() + } + }) + window.on('closed', clearDelayedRepaint) +} + function isMacAppPasteInput(input: Electron.Input): boolean { return ( process.platform === 'darwin' && @@ -293,18 +335,13 @@ export function createMainWindow( setTrustedUIRendererWebContentsId(rendererWebContentsId) if (process.platform === 'darwin') { - // Why: persistent browser webviews use separate compositor layers, and on - // recent macOS releases those layers can fail to repaint after occlusion or - // restore. Disabling main-window throttling and forcing a repaint on - // visibility transitions hardens Orca against black-surface failures during - // browser-tab restore and tab switching. - mainWindow.webContents.setBackgroundThrottling(false) - mainWindow.on('restore', () => { - forceRepaint(mainWindow) - }) - mainWindow.on('show', () => { - forceRepaint(mainWindow) - }) + // Why: browser-guest surfaces are kept alive by their own per-guest + // unthrottle (browser-manager attach), so the main window can throttle + // normally while hidden/occluded instead of rendering at full rate. + // Toggling must happen only while visible: flipping it on a hidden window + // desyncs Chromium's frame evictor and blanks the surface (electron#42378). + mainWindow.webContents.setBackgroundThrottling(true) + installMacosVisibilityRepaint(mainWindow) } // Why: a focus-preserving system/display wake fires no window focus or From e90dc38f01670ff2da0d1eb36fc051961efca09f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:31:36 -0700 Subject: [PATCH 07/35] fix(dev): stabilize macOS safeStorage Keychain key name across dev branches (#9400) Co-authored-by: Orca --- src/main/index.ts | 7 ++++++- src/main/menu/register-app-menu.ts | 5 ++++- src/main/startup/dev-instance-identity.test.ts | 13 +++++++++++++ src/main/startup/dev-instance-identity.ts | 10 ++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 9278eb043ec..312b8bfd9cf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1766,7 +1766,11 @@ app.whenReady().then(async () => { } ) electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) - app.setName(devInstanceIdentity.name) + // Why: setName drives the macOS safeStorage Keychain item name. Use the stable + // appName (not the per-branch `name`) so dev branches share one key and don't + // re-prompt per branch; the per-branch label still shows via window title, + // renderer identity, and the app-menu label passed to registerAppMenu below. + app.setName(devInstanceIdentity.appName) // Why: managed WSL launchers live outside the Windows app bundle, so keep // their launcher and bridge contract synchronized across app updates. @@ -2138,6 +2142,7 @@ app.whenReady().then(async () => { logStartupMilestone('i18n-ready') registerAppMenu({ + appMenuLabel: devInstanceIdentity.name, onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), onBeforeReload: ({ ignoreCache, webContentsId }) => { if (mainWindow?.webContents.id === webContentsId) { diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index c36aca6973a..2f3a3d70a97 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -37,6 +37,9 @@ type RegisterAppMenuOptions = { onToggleAppearance: (key: AppearanceMenuKey) => void getAppearanceState: () => AppearanceMenuState getKeybindings?: () => KeybindingOverrides | undefined + // Why: the macOS app-menu title. Passed the per-branch dev label since + // app.name is now pinned to a stable value for Keychain-key stability. + appMenuLabel?: string } function buildAndApplyMenu(options: RegisterAppMenuOptions): void { @@ -130,7 +133,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { // redundant "Orca" entry with roles that don't apply, so we omit it there // and distribute its items across File / Help instead. const macAppMenu: Electron.MenuItemConstructorOptions = { - label: app.name, + label: options.appMenuLabel ?? app.name, submenu: [ { role: 'about' }, checkForUpdatesItem, diff --git a/src/main/startup/dev-instance-identity.test.ts b/src/main/startup/dev-instance-identity.test.ts index 2021dfed224..2b06e85dda4 100644 --- a/src/main/startup/dev-instance-identity.test.ts +++ b/src/main/startup/dev-instance-identity.test.ts @@ -5,6 +5,7 @@ describe('dev-instance-identity', () => { it('keeps packaged identity stable', () => { expect(getDevInstanceIdentity(false, {})).toMatchObject({ name: 'Orca', + appName: 'Orca', isDev: false, devLabel: null, dockBadgeLabel: null, @@ -12,6 +13,18 @@ describe('dev-instance-identity', () => { }) }) + it('pins a stable dev appName across branches so the safeStorage key does not churn', () => { + const a = getDevInstanceIdentity(true, { ORCA_DEV_BRANCH: 'feature/a' }) + const b = getDevInstanceIdentity(true, { ORCA_DEV_BRANCH: 'feature/b' }) + + // Per-branch label differs (window title / app menu)... + expect(a.name).not.toBe(b.name) + // ...but the Keychain-driving appName is identical and distinct from prod. + expect(a.appName).toBe('Orca Dev') + expect(b.appName).toBe('Orca Dev') + expect(a.appName).not.toBe('Orca') + }) + it('derives a readable dev label from worktree and branch env', () => { const identity = getDevInstanceIdentity(true, { ORCA_DEV_REPO_ROOT: '/repo/worktrees/dev-indicator', diff --git a/src/main/startup/dev-instance-identity.ts b/src/main/startup/dev-instance-identity.ts index b375e88bba7..6d13541ea1f 100644 --- a/src/main/startup/dev-instance-identity.ts +++ b/src/main/startup/dev-instance-identity.ts @@ -8,6 +8,11 @@ const MAX_LABEL_LENGTH = 80 export type DevInstanceIdentity = AppIdentity & { appUserModelId: string + // Why: drives app.setName → the macOS safeStorage Keychain item name + // (" Safe Storage"). Kept stable across dev branches (unlike the + // per-branch `name`) so every dev instance shares one Keychain key instead of + // creating a new one per branch and re-prompting. Distinct from prod's 'Orca'. + appName: string } function cleanEnvValue(value: string | undefined): string | null { @@ -50,6 +55,7 @@ export function getDevInstanceIdentity( if (!isDev) { return { name: BASE_APP_NAME, + appName: BASE_APP_NAME, isDev: false, devLabel: null, devBranch: null, @@ -71,6 +77,10 @@ export function getDevInstanceIdentity( return { name: dockTitle, + // Why: one stable Keychain key ('Orca Dev Safe Storage') for all dev + // branches; the per-branch identity still shows via `name` (window title, + // app menu, renderer label). + appName: `${BASE_APP_NAME} Dev`, isDev: true, devLabel, devBranch: branch, From a02389cea809239c7d38409e62b957b2dcaf66e1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:40:49 -0700 Subject: [PATCH 08/35] Update code comments section in AGENTS.md Clarified guidelines for code comments, emphasizing the importance of explaining the 'why' behind non-obvious code. --- AGENTS.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c117478e84..1fd213f742e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,7 @@ All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. ## Code Comments: Document the "Why", Briefly - -When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does. - -Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point. - -The comment's audience is the next person reading that line, not a PR description. If a comment lists call sites, recounts the crash/history, or re-describes the code below it, cut it to the one non-obvious fact: +When code is non-obvious, Add code comments explaining **why** (not HOW). BE CONCISE — ideally 1 line. ```ts // 🚫 Over-explained — narrates the crash, the mechanism, where the helper lives, and every caller: From 64dae13bc9b5bfb7d27ae42d8f593913344c064e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 20 Jul 2026 01:18:27 -0400 Subject: [PATCH 09/35] fix(sidebar): stop agent 'working' spinner freezing as a broken ring under reduced motion (#9515) Under prefers-reduced-motion (Windows 'Animation effects' off), the agent 'working' spinner froze mid-rotation as a partial (3/4) ring, reading as a broken spinner in the terminal tab and worktree card. Fill the top border so it becomes a complete static ring, and apply the same treatment to StatusIndicator so the sidebar dot honors reduced motion consistently instead of stepping. Mirrors the existing feature-tour-preview-glyphs pattern. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/renderer/src/components/AgentStateDot.test.ts | 3 +++ src/renderer/src/components/AgentStateDot.tsx | 5 ++++- .../src/components/sidebar/StatusIndicator.test.ts | 4 ++++ src/renderer/src/components/sidebar/StatusIndicator.tsx | 8 ++++++-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 2e17d0423df..11297224433 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -24,6 +24,9 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-t-transparent') expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') expect(markup).toContain('motion-reduce:animate-none') + // Why: under reduced motion the top border is filled so the frozen ring + // reads as a complete static marker, not a broken partial spinner. + expect(markup).toContain('motion-reduce:border-t-yellow-500') expect(markup).not.toContain('animate-spin') }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 9a4fe792e13..7543de46fac 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -78,7 +78,10 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn( // Why: match the sidebar worktree spinner's stepped cadence so // long-running visible agents do not keep a full-frame-rate loop. - 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none', + // Why: under reduced motion the spin is disabled, so fill the top + // border too — a frozen transparent-top ring reads as a broken + // spinner; a complete ring reads as an intentional static marker. + 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none motion-reduce:border-t-yellow-500', inner )} /> diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index 5f9061eb937..0179a0bac02 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -23,6 +23,10 @@ describe('StatusIndicator', () => { expect(classNames).toContain('border-yellow-500') expect(classNames).toContain('border-t-transparent') expect(classNames).toContain('[animation:spin_1s_steps(12,end)_infinite]') + // Why: under reduced motion the spin is disabled and the top border filled, + // so the dot stays a complete static ring rather than freezing mid-rotation. + expect(classNames).toContain('motion-reduce:animate-none') + expect(classNames).toContain('motion-reduce:border-t-yellow-500') expect(classNames).not.toContain('animate-spin') }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 34c1491095f..c181e0c297d 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -34,8 +34,12 @@ const StatusIndicator = React.memo(function StatusIndicator({ {...rest} > {/* Why: a stepped spin preserves the worker-is-running affordance while - avoiding a full-refresh-rate compositor loop for long agent runs. */} - + avoiding a full-refresh-rate compositor loop for long agent runs. + Why: honor reduced motion (Windows "Animation effects" off → + prefers-reduced-motion) by stopping the spin and filling the top + border, so the dot stays a complete static ring instead of a + partial ring frozen mid-rotation that reads as a broken spinner. */} + ) } From 4d25de383e0c294c539b6101281e30a44c772120 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:53:32 -0700 Subject: [PATCH 10/35] perf(renderer): stop agent spinners from keeping the frame pipeline awake (one shared clock, hidden-window stop) (#9380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(renderer): drive agent working-spinners from one shared clock Per-element infinite CSS spin animations kept Chromium's frame pipeline awake for the whole agent run — measured live (interleaved A/B/A/B, renderer+GPU): 86.0/76.0 ms CPU/s with the CSS animation vs 61.0/61.0 with the clock, one working agent, and the CSS cost scales per element while the clock is one flat timer for N spinners. The clock ticks 12 steps/s at 30° — frame-for-frame identical to the retired animation — writes style.transform on registered elements, and stops on document hidden (native visibilitychange post-#9395), under prefers-reduced-motion (static ring keeps the filled top border from #9515), and when the last spinner unmounts. The stale-visibility latch keeps it spinning when proven user input contradicts the occlusion tracker. Co-authored-by: Orca * fix(github): restore explicit space after Filter fallback in PRFilterSections Pre-existing failure on main: i18n-jsx-spacing-guard requires {' '} after the 'Filter' translate fallback, but the file had a bare JSX space (which oxfmt collapses). Wrap the subject in a span so the explicit {' '} survives formatting, matching the pattern in the other guarded files. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../src/components/AgentStateDot.test.ts | 11 +- src/renderer/src/components/AgentStateDot.tsx | 13 +- .../src/components/AgentWorkingSpinner.tsx | 23 +++ .../components/github/PRFilterSections.tsx | 3 +- .../sidebar/StatusIndicator.test.ts | 22 +-- .../components/sidebar/StatusIndicator.tsx | 9 +- .../tab-bar/TerminalTabLeadingIcon.test.tsx | 2 +- .../src/lib/agent-spinner-clock.test.ts | 141 ++++++++++++++++++ src/renderer/src/lib/agent-spinner-clock.ts | 128 ++++++++++++++++ 9 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/AgentWorkingSpinner.tsx create mode 100644 src/renderer/src/lib/agent-spinner-clock.test.ts create mode 100644 src/renderer/src/lib/agent-spinner-clock.ts diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 11297224433..40c2bea60f3 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -22,12 +22,15 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') - expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') - expect(markup).toContain('motion-reduce:animate-none') - // Why: under reduced motion the top border is filled so the frozen ring - // reads as a complete static marker, not a broken partial spinner. + // Why: rotation comes from the shared agent-spinner clock (which also + // honors prefers-reduced-motion), not a per-element CSS animation that + // would keep the compositor awake. + expect(markup).toContain('data-agent-spinner') + // Why: under reduced motion the top border is filled so the static ring + // reads as a complete marker, not a broken partial spinner (#9515). expect(markup).toContain('motion-reduce:border-t-yellow-500') expect(markup).not.toContain('animate-spin') + expect(markup).not.toContain('animation:spin') }) it('renders done as an emerald check icon', () => { diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 7543de46fac..a5c50dad1fe 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,6 +1,7 @@ import React from 'react' import { CircleCheck } from 'lucide-react' import { cn } from '@/lib/utils' +import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' // Why: shared state-indicator primitive so the dashboard and the sidebar's // agent hover share a single state vocabulary. Most states render as a dot; @@ -74,17 +75,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn('inline-flex shrink-0 items-center justify-center', box, className)} aria-label={agentStateLabel(state)} > - + ) } diff --git a/src/renderer/src/components/AgentWorkingSpinner.tsx b/src/renderer/src/components/AgentWorkingSpinner.tsx new file mode 100644 index 00000000000..7e3d3302a7e --- /dev/null +++ b/src/renderer/src/components/AgentWorkingSpinner.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { agentSpinnerRef } from '@/lib/agent-spinner-clock' + +// Why: the working-state ring must not carry its own infinite CSS animation — +// that keeps the compositor awake per element for the whole agent run. The +// shared agent-spinner clock rotates every mounted ring in phase and stops +// cleanly when nothing can be seen. Callers size it via className (size-2 etc.). +export function AgentWorkingSpinner({ className }: { className?: string }): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/github/PRFilterSections.tsx b/src/renderer/src/components/github/PRFilterSections.tsx index 6f65eee8e55..d704ea12754 100644 --- a/src/renderer/src/components/github/PRFilterSections.tsx +++ b/src/renderer/src/components/github/PRFilterSections.tsx @@ -208,7 +208,8 @@ export function SectionMenu({ return (
- {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} {subject} + {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')}{' '} + {subject}
{rows.map((row) => (