From 3a70078ab9972f61a364c064f21e2301b53f360d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:36:49 -0700 Subject: [PATCH 1/9] fix(daemon): prevent PAM rejection restart cascades (#11911) * fix(daemon): back off transient PAM rejection retirement * fix(daemon): rebaseline PAM evidence after sleep --- config/reliability-gates.jsonc | 80 ++++++++++++++++++- src/main/daemon/daemon-entry.ts | 1 + .../macos-login-session-death-watch.test.ts | 80 ++++++++++++++++--- .../daemon/macos-login-session-death-watch.ts | 45 +++++++++-- 4 files changed, 190 insertions(+), 16 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f6af7d2eaa7..2785645d621 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-31", + "updatedAt": "2026-08-01", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -10,6 +10,84 @@ } }, "gates": [ + { + "id": "terminal-provider.login-session-retirement", + "title": "macOS login-session retirement preserves live daemons through transient rejection bursts", + "maturity": "experimental", + "protection": "partial", + "owner": "daemon-terminal", + "layer": "macos-daemon-lifecycle", + "surfaces": [ + "GUI-spawned macOS daemon", + "local PTY survival", + "daemon reconnect and replacement" + ], + "platforms": ["macos"], + "providers": ["local-daemon"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "A deterministic fake clock covers short-burst recovery, sustained session death, event-trigger preemption, timer suspension, resolver corroboration, and shutdown cancellation. The production-only watch remains behind the macOS GUI launch flag; SSH/headless, WSL, Linux, Windows, mobile, and relay paths are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11749", + "https://github.com/stablyai/orca/issues/7936" + ], + "invariant": "A GUI-spawned macOS daemon may retire for PAM rejections only after it previously accepted login wrapping, receives three conclusive rejections spanning at least one uninterrupted 120-second observation window, and observes explicitly unhealthy in-process resolver state. A conclusive acceptance or a sleep/App Nap-sized timer gap resets the rejection window, and client or PTY activity cannot shorten its scheduled backoff.", + "oracle": "Arm the watch with an accepted probe, inject three rejections over 20 seconds plus unhealthy resolver state, then inject client and PTY activity and require zero resolver reads, zero retirement calls, and exactly four probes until the 120-second boundary. Return acceptance at that boundary and require the daemon to survive. Jump wall time by one hour without firing the scheduled timer, require rejection evidence to restart after wake, and again recover without retirement. Separately keep rejecting through an uninterrupted boundary and require one retirement, while healthy or unknown resolver state suppresses it and stop aborts an in-flight resolver check.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot" + ], + "testFiles": ["src/main/daemon/macos-login-session-death-watch.test.ts"], + "assertionRefs": [ + { + "file": "src/main/daemon/macos-login-session-death-watch.test.ts", + "assertions": [ + "preserves the daemon when a short PAM rejection burst recovers after wake", + "does not count a suspended timer gap as rejection evidence", + "retires only after sustained conclusive rejections with a degraded resolver", + "suppresses retirement while resolver health is healthy or unknown, then retires on explicit degradation", + "stop prevents an in-flight resolver check from retiring the daemon" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 0.1, + "summary": "The focused state-machine suite passed 19 tests, including the field-shaped 20-second rejection burst, suspended-timer rebaselining, bounded recovery retry, sustained-death convergence, resolver suppression, trigger coalescing, and shutdown cancellation." + } + ], + "runtimeBudget": { + "p95Seconds": 2, + "scope": "deterministic login-session death-watch state-machine suite" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic suite passes locally; focused CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "On origin/main, the field-shaped accepted then three-rejection sequence calls onRetire after 20 seconds and fails the recovery oracle. With the minimum observation window and non-preemptible rejection schedule, the identical oracle preserves the daemon and recovers at the boundary." + }, + "performanceBudget": { + "required": true, + "evidence": "The watch retains one timer and one in-flight probe. A rejection burst runs the existing two 10-second confirmation probes, then one recovery probe at the 120-second boundary; client and PTY activity cannot pull that deadline earlier. Resolver work is skipped until the time floor. No polling loop, startup await, session scan, renderer work, provider fanout, listener, or retained payload was added." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Capture field evidence from macOS sleep/wake and a full GUI logout without a false retirement or missed stale-session recovery.", + "Keep the exact probe-count, no-early-resolver, trigger-preemption, acceptance-reset, and shutdown-abort assertions green." + ], + "knownGaps": [ + "A real dead macOS GUI login session cannot be fabricated without ending the runner's login session; sustained-death coverage uses deterministic PAM and resolver oracles.", + "The two-minute production window and one-hour timer suspension are covered by fake-clock tests rather than a real sleep/wake run.", + "No multi-process aggregate throttle is added; each stale daemon independently obeys the same non-preemptible observation window." + ], + "demotionRule": "Keep experimental or demote if any activity trigger shortens the rejection window, a transient burst reaches resolver retirement authority, sustained dead-session evidence no longer converges, shutdown permits late retirement, or the focused gate flakes without an identified product or harness fault." + }, { "id": "ssh-relay.staged-upload-recovery", "title": "SSH relay uploads remain retryable before the shared install lock", diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 215786fb472..4d98e2a6690 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -210,6 +210,7 @@ async function main(): Promise { timing: { periodicProbeMs: 2_000, rejectionRecheckMs: 500, + minimumRejectionSpanMs: 2_000, ptyExitDebounceMs: 200, clientActivityMinGapMs: 1_000, minProbeGapMs: 100 diff --git a/src/main/daemon/macos-login-session-death-watch.test.ts b/src/main/daemon/macos-login-session-death-watch.test.ts index 1531a33ab20..fa983508ec5 100644 --- a/src/main/daemon/macos-login-session-death-watch.test.ts +++ b/src/main/daemon/macos-login-session-death-watch.test.ts @@ -38,7 +38,7 @@ class FakeClock { if (!due) { break } - this.nowMs = due.at + this.nowMs = Math.max(this.nowMs, due.at) due.cleared = true due.callback() // Why: probes are async; let their promise chains settle before firing the next timer. @@ -47,6 +47,10 @@ class FakeClock { this.nowMs = target } + suspend(ms: number): void { + this.nowMs += ms + } + pendingCount(): number { return this.timers.filter((t) => !t.cleared).length } @@ -84,6 +88,7 @@ function createWatch( timing: { periodicProbeMs: 120_000, rejectionRecheckMs: 10_000, + minimumRejectionSpanMs: 120_000, ptyExitDebounceMs: 2_000, clientActivityMinGapMs: 30_000, minProbeGapMs: 5_000, @@ -102,9 +107,9 @@ function createWatch( } describe('MacosLoginSessionDeathWatch', () => { - it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => { + it('retires only after sustained conclusive rejections with a degraded resolver', async () => { const { watch, clock, onRetire } = createWatch({ - outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED] + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED] }) watch.start() await drainMicrotasks() @@ -113,7 +118,9 @@ describe('MacosLoginSessionDeathWatch', () => { await clock.advance(120_000) // periodic → rejection 1 await clock.advance(10_000) // recheck → rejection 2 expect(onRetire).not.toHaveBeenCalled() - await clock.advance(10_000) // recheck → rejection 3 → retire + await clock.advance(10_000) // recheck → rejection 3 → deferred + expect(onRetire).not.toHaveBeenCalled() + await clock.advance(100_000) // rejection 3 after the observation window → retire expect(onRetire).toHaveBeenCalledWith({ cause: 'pam-rejections', rejections: 3, @@ -121,6 +128,57 @@ describe('MacosLoginSessionDeathWatch', () => { }) }) + it('preserves the daemon when a short PAM rejection burst recovers after wake', async () => { + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire, probe } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, ACCEPTED], + readResolverHealth + }) + watch.start() + await drainMicrotasks() + + await clock.advance(120_000) + await clock.advance(10_000) + await clock.advance(10_000) + + expect(probe).toHaveBeenCalledTimes(4) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + + watch.notifyClientActivity() + watch.notifyPtyExit() + await clock.advance(99_999) + expect(probe).toHaveBeenCalledTimes(4) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(5) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + }) + + it('does not count a suspended timer gap as rejection evidence', async () => { + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire, probe } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED, ACCEPTED], + readResolverHealth + }) + watch.start() + await drainMicrotasks() + + await clock.advance(120_000) + clock.suspend(60 * 60 * 1000) + await clock.advance(0) + await clock.advance(10_000) + await clock.advance(10_000) + + expect(probe).toHaveBeenCalledTimes(5) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + + await clock.advance(100_000) + expect(probe).toHaveBeenCalledTimes(6) + expect(onRetire).not.toHaveBeenCalled() + }) + it('never retires when the session was never conclusively accepted', async () => { const { watch, clock, onRetire } = createWatch({ outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED] @@ -183,20 +241,21 @@ describe('MacosLoginSessionDeathWatch', () => { 'suppresses retirement while resolver health is %s, then retires on explicit degradation', async (initialResolverHealth) => { const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({ - outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED] + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED, REJECTED] }) setResolverHealth(initialResolverHealth) watch.start() await drainMicrotasks() await clock.advance(120_000) await clock.advance(10_000) - await clock.advance(10_000) // threshold reached but resolver did not corroborate death + await clock.advance(10_000) + await clock.advance(100_000) // threshold reached but resolver did not corroborate death expect(onRetire).not.toHaveBeenCalled() const probesAtSuppression = probe.mock.calls.length setResolverHealth('unhealthy') - await clock.advance(10_000) + await clock.advance(119_999) expect(probe).toHaveBeenCalledTimes(probesAtSuppression) - await clock.advance(110_000) // suppressed states return to the bounded periodic cadence + await clock.advance(1) // suppressed states return to the bounded periodic cadence expect(onRetire).toHaveBeenCalledTimes(1) } ) @@ -334,7 +393,7 @@ describe('MacosLoginSessionDeathWatch', () => { resolveHealth = resolve }) const { watch, clock, onRetire } = createWatch({ - outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED], + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED], readResolverHealth: (signal) => { resolverSignal = signal return resolverHealth @@ -344,7 +403,8 @@ describe('MacosLoginSessionDeathWatch', () => { await drainMicrotasks() await clock.advance(120_000) await clock.advance(10_000) - await clock.advance(10_000) // retirement is now waiting on resolver health + await clock.advance(10_000) + await clock.advance(100_000) // retirement is now waiting on resolver health watch.stop() expect(resolverSignal?.aborted).toBe(true) diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts index d0cadccac18..ce803ff409b 100644 --- a/src/main/daemon/macos-login-session-death-watch.ts +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -2,12 +2,12 @@ import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell' import type { DaemonFileLog } from './daemon-file-log' import type { SystemResolverHealth } from './types' -// Why: three conclusive PAM verdicts spread over recheck intervals keep a transient -// rejection storm (PAM db reload, OS update) from retiring a daemon whose login -// session is still alive; a real logout rejects conclusively on every probe. +// Why: retain count confirmation while the elapsed window below blocks short bursts. const REQUIRED_CONSECUTIVE_REJECTIONS = 3 const PERIODIC_PROBE_MS = 120_000 const REJECTION_RECHECK_MS = 10_000 +// Why: a full periodic window separates logout from short wake/PAM recovery bursts. +const MINIMUM_REJECTION_SPAN_MS = PERIODIC_PROBE_MS const PTY_EXIT_DEBOUNCE_MS = 2_000 // Why: a client hello right after login is the fastest death signal for a stale // daemon, but steady reconnects must not turn hellos into a PAM probe storm. @@ -35,6 +35,7 @@ export type MacosLoginSessionDeathWatchOptions = { timing?: Partial<{ periodicProbeMs: number rejectionRecheckMs: number + minimumRejectionSpanMs: number ptyExitDebounceMs: number clientActivityMinGapMs: number minProbeGapMs: number @@ -65,6 +66,7 @@ export class MacosLoginSessionDeathWatch { private readonly clock: WatchClock private readonly periodicProbeMs: number private readonly rejectionRecheckMs: number + private readonly minimumRejectionSpanMs: number private readonly ptyExitDebounceMs: number private readonly clientActivityMinGapMs: number private readonly minProbeGapMs: number @@ -73,6 +75,7 @@ export class MacosLoginSessionDeathWatch { // a host where the wrapper never worked has no death signal to trust. private armed = false private consecutiveRejections = 0 + private firstRejectionAtMs: number | null = null private lastProbeStartedAtMs: number | null = null private probeInFlight = false private stopped = false @@ -99,6 +102,7 @@ export class MacosLoginSessionDeathWatch { } this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS + this.minimumRejectionSpanMs = opts.timing?.minimumRejectionSpanMs ?? MINIMUM_REJECTION_SPAN_MS this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS @@ -169,9 +173,10 @@ export class MacosLoginSessionDeathWatch { } this.scheduledProbeAtMs = this.clock.now() + delayMs this.scheduledProbeTimer = this.clock.setTimeout(() => { + const scheduledAtMs = this.scheduledProbeAtMs this.scheduledProbeTimer = null this.scheduledProbeAtMs = null - void this.runProbe(trigger) + void this.runProbe(trigger, scheduledAtMs ?? undefined) }, delayMs) } @@ -181,6 +186,9 @@ export class MacosLoginSessionDeathWatch { private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void { const requestedAtMs = this.clock.now() + delayMs + if (this.consecutiveRejections > 0 && this.scheduledProbeAtMs !== null) { + return + } if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) { return } @@ -200,7 +208,7 @@ export class MacosLoginSessionDeathWatch { } } - private async runProbe(trigger: string): Promise { + private async runProbe(trigger: string, scheduledAtMs?: number): Promise { if (this.stopped || this.retired) { return } @@ -209,6 +217,20 @@ export class MacosLoginSessionDeathWatch { this.retainPendingProbe(trigger) return } + if (this.consecutiveRejections > 0 && scheduledAtMs === undefined) { + return + } + const timerGapMs = + scheduledAtMs === undefined ? 0 : Math.max(0, this.clock.now() - scheduledAtMs) + if (this.consecutiveRejections > 0 && timerGapMs > this.minProbeGapMs * 3) { + // Why: sleep/App Nap pauses probes; elapsed wall time is not rejection evidence. + this.consecutiveRejections = 0 + this.firstRejectionAtMs = null + this.log.log('login-session-rejection-window-reset', { + cause: 'timer-gap', + timerGapMs + }) + } const elapsedSinceProbe = this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs if ( @@ -246,6 +268,7 @@ export class MacosLoginSessionDeathWatch { } this.armed = true this.consecutiveRejections = 0 + this.firstRejectionAtMs = null this.scheduleNextProbe(this.periodicProbeMs) return } @@ -254,6 +277,7 @@ export class MacosLoginSessionDeathWatch { this.scheduleNextProbe(this.periodicProbeMs) return } + this.firstRejectionAtMs ??= this.clock.now() this.consecutiveRejections++ this.log.log('login-session-probe-rejected', { trigger, @@ -263,6 +287,17 @@ export class MacosLoginSessionDeathWatch { this.scheduleNextProbe(this.rejectionRecheckMs) return } + const rejectionSpanMs = this.clock.now() - this.firstRejectionAtMs + if (rejectionSpanMs < this.minimumRejectionSpanMs) { + const retryInMs = this.minimumRejectionSpanMs - rejectionSpanMs + this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1 + this.log.log('login-session-retire-deferred', { + cause: 'pam-rejections', + retryInMs + }) + this.scheduleNextProbe(retryInMs) + return + } await this.retireIfResolverDegraded(() => { this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1 }, abortController.signal) From e531e796b279ab6ad1e399e2ae3c8c764be09f38 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:56:52 -0700 Subject: [PATCH 2/9] fix(relay): chunk fs.changed notifications to fit the client frame capacity (#11917) * fix(relay): chunk fs.changed notifications to fit the client frame capacity emitRelayWatcherEvents published an entire watcher batch as one fs.changed notification on the ordinary lane. A batch runs routinely tens of times over the per-frame producer capacity (12,288 bytes against a Node <=21 socket, 49,152 against Node >=22), and an over-capacity frame there is not queued or trimmed -- notify() fails admission and closes the client, tearing down every in-flight request on that connection. Regressed in 5f7807497e (#11005). The emitter now sizes chunks against the tightest capacity across attached clients, measured in encoded bytes through the same envelope admission measures, and preserves event order. A single event too large for an empty envelope cannot be chunked, so the ordinary-lane close remains its backstop: a silent drop on this lane has no resync contract. * chore(relay): shorten watcher chunking comments --- src/relay/dispatcher.ts | 20 ++ .../relay-filesystem-watch-registry.test.ts | 2 + src/relay/relay-watcher-event-emitter.ts | 42 ++- .../relay-watcher-frame-chunking.test.ts | 337 ++++++++++++++++++ .../relay-watcher-parent-removal.test.ts | 16 +- ...elay-watcher-pending-setup-waiters.test.ts | 7 +- 6 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 src/relay/relay-watcher-frame-chunking.test.ts diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 3809b9f5f80..c6a1a77ec96 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -388,6 +388,26 @@ export class RelayDispatcher { ) } + // notify() broadcasts one frame, so chunks must fit the smallest attached capacity. + broadcastProducerFrameCapacity(): number | undefined { + if (this.disposed) { + return undefined + } + const clients = this.activeClients() + if (clients.length === 0) { + return undefined + } + return Math.min(...clients.map((client) => client.writer.producerFrameCapacity)) + } + + notificationFrameBytes(method: string, params?: Record): number { + return this.estimateFrameBytes({ + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + }) + } + feed(data: Buffer): void { this.feedForClient(this.primaryClient, data) } diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 9a21fb9eace..8c4c0134a2a 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -64,6 +64,8 @@ function createDispatcher() { return { notify: vi.fn(), notifyClient: vi.fn(), + broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), + notificationFrameBytes: vi.fn(() => 64), onClientDetached: vi.fn((listener: (clientId: number) => void) => { detached.add(listener) return () => detached.delete(listener) diff --git a/src/relay/relay-watcher-event-emitter.ts b/src/relay/relay-watcher-event-emitter.ts index 1a575c360fa..05cdd68c281 100644 --- a/src/relay/relay-watcher-event-emitter.ts +++ b/src/relay/relay-watcher-event-emitter.ts @@ -1,6 +1,14 @@ import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' import type { RelayDispatcher } from './dispatcher' +const FS_CHANGED = 'fs.changed' + +type RelayWatcherEventPayload = { + kind: string + absolutePath: string + isDirectory?: boolean +} + export function emitRelayWatcherEvents( dispatcher: RelayDispatcher, closed: boolean, @@ -9,14 +17,36 @@ export function emitRelayWatcherEvents( if (closed || events.length === 0) { return } - // Watcher floods stay on the bounded producer lane; its cap closes the stale generation. - dispatcher.notify('fs.changed', { - events: events.map((event) => ({ + const frameCapacity = dispatcher.broadcastProducerFrameCapacity() + if (frameCapacity === undefined) { + return + } + // Whole watcher batches exceed the ordinary-lane cap; byte-pack ordered chunks instead. + const envelopeBytes = dispatcher.notificationFrameBytes(FS_CHANGED, { events: [] }) + let chunk: RelayWatcherEventPayload[] = [] + let chunkBytes = envelopeBytes + const flush = (): void => { + if (chunk.length === 0) { + return + } + dispatcher.notify(FS_CHANGED, { events: chunk }) + chunk = [] + chunkBytes = envelopeBytes + } + for (const event of events) { + const payload: RelayWatcherEventPayload = { kind: event.type, absolutePath: event.path, ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) - })) - }) + } + const payloadBytes = Buffer.byteLength(JSON.stringify(payload)) + if (chunk.length > 0 && chunkBytes + 1 + payloadBytes > frameCapacity) { + flush() + } + chunkBytes += payloadBytes + (chunk.length === 0 ? 0 : 1) + chunk.push(payload) + } + flush() } export function emitRelayWatcherOverflow( @@ -25,7 +55,7 @@ export function emitRelayWatcherOverflow( closed: boolean ): void { if (!closed) { - dispatcher.notify('fs.changed', { + dispatcher.notify(FS_CHANGED, { events: [{ kind: 'overflow', absolutePath: rootPath }] }) } diff --git a/src/relay/relay-watcher-frame-chunking.test.ts b/src/relay/relay-watcher-frame-chunking.test.ts new file mode 100644 index 00000000000..5d505fb8aaa --- /dev/null +++ b/src/relay/relay-watcher-frame-chunking.test.ts @@ -0,0 +1,337 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayDispatcher, type RelayClientSinkOptions } from './dispatcher' +import { HEADER_LENGTH, encodeJsonRpcFrame, parseJsonRpcMessage } from './protocol' +import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' +import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' + +// Node >=22 defaults the socket highWaterMark to 64 KiB, Node <=21 to 16 KiB. +const NODE_22_HIGH_WATER_MARK = 64 * 1024 +const NODE_22_PRODUCER_CAPACITY = 49_152 +const NODE_21_HIGH_WATER_MARK = 16 * 1024 +const NODE_21_PRODUCER_CAPACITY = 12_288 + +type WatcherEventPayload = { + kind: string + absolutePath: string + isDirectory?: boolean +} + +type ClientCapture = { + frames: Buffer[] + framedEvents: () => WatcherEventPayload[][] + fsChangedEvents: () => WatcherEventPayload[] +} + +function captureFrames(): { capture: ClientCapture; write: (frame: Buffer) => boolean } { + const frames: Buffer[] = [] + const decode = (frame: Buffer): { method: string; events: WatcherEventPayload[] } => { + const payloadLength = frame.readUInt32BE(9) + const message = parseJsonRpcMessage( + frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength) + ) as { method?: string; params?: { events?: WatcherEventPayload[] } } + return { method: message.method ?? '', events: message.params?.events ?? [] } + } + const framedEvents = (): WatcherEventPayload[][] => + frames + .map((frame) => decode(frame)) + .filter((decoded) => decoded.method === 'fs.changed') + .map((decoded) => decoded.events) + return { + capture: { + frames, + framedEvents, + fsChangedEvents: () => framedEvents().flat() + }, + write: (frame) => { + frames.push(Buffer.from(frame)) + return true + } + } +} + +function createDispatcher( + highWaterMark: number, + sinkOptions: RelayClientSinkOptions = {} +): { + dispatcher: RelayDispatcher + capture: ClientCapture + detached: number[] +} { + const { capture, write } = captureFrames() + const detached: number[] = [] + const dispatcher = new RelayDispatcher(write, { + writableHighWaterMark: () => highWaterMark, + writableLength: () => 0, + ...sinkOptions + }) + dispatcher.onClientDetached((clientId) => detached.push(clientId)) + return { dispatcher, capture, detached } +} + +function watcherBatch(count: number): WatcherProcessEvent[] { + return Array.from({ length: count }, (_, index) => ({ + type: index % 3 === 0 ? 'create' : index % 3 === 1 ? 'update' : 'delete', + path: `/Users/dev/workspaces/orca/node_modules/.pnpm/@scope+package@1.2.3/dist/chunk-${String(index).padStart(6, '0')}.js`, + isDirectory: false + })) as WatcherProcessEvent[] +} + +// Each path encodes beyond its UTF-16 length, exposing character-based budgets. +function multiByteWatcherBatch(count: number): WatcherProcessEvent[] { + return Array.from({ length: count }, (_, index) => ({ + type: index % 3 === 0 ? 'create' : index % 3 === 1 ? 'update' : 'delete', + path: `/Users/dev/プロジェクト/日本語のディレクトリ/файлы/🚀-переклад/"quoted"\\escaped\\chunk-${String(index).padStart(6, '0')}.js`, + isDirectory: false + })) as WatcherProcessEvent[] +} + +function frameBytesFor(events: readonly WatcherEventPayload[]): number { + return encodeJsonRpcFrame({ jsonrpc: '2.0', method: 'fs.changed', params: { events } }, 0, 0) + .length +} + +// Real encodes keep this boundary oracle independent from the emitter's arithmetic. +function batchEncodingTo(targetBytes: number): WatcherProcessEvent[] { + const events = watcherBatch(400) + let fitting = 1 + while ( + fitting < events.length && + frameBytesFor(expectedPayloads(events.slice(0, fitting + 1))) <= targetBytes + ) { + fitting++ + } + const batch = events.slice(0, fitting) + const last = batch[fitting - 1] + const padding = targetBytes - frameBytesFor(expectedPayloads(batch)) + batch[fitting - 1] = { ...last, path: `${last.path}${'a'.repeat(padding)}` } + return batch +} + +function expectedPayloads(events: readonly WatcherProcessEvent[]): WatcherEventPayload[] { + return events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + })) +} + +// Moving the next event forward must exceed capacity, catching needless splits. +function expectMaximallyPackedChunks(capture: ClientCapture, capacity: number): void { + const chunks = capture.framedEvents() + expect(chunks.length).toBeGreaterThan(1) + for (let index = 0; index + 1 < chunks.length; index++) { + expect(frameBytesFor([...chunks[index], chunks[index + 1][0]])).toBeGreaterThan(capacity) + } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('relay watcher fs.changed frame chunking', () => { + it('delivers a 5000-event batch to a Node<=21 client without closing it', () => { + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + const events = watcherBatch(5000) + + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + expectMaximallyPackedChunks(capture, NODE_21_PRODUCER_CAPACITY) + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('delivers a 5000-event batch to a Node>=22 client without closing it', () => { + const { dispatcher, capture, detached } = createDispatcher(NODE_22_HIGH_WATER_MARK) + const events = watcherBatch(5000) + + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_22_PRODUCER_CAPACITY) + } + expectMaximallyPackedChunks(capture, NODE_22_PRODUCER_CAPACITY) + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('sizes chunks in encoded bytes for multi-byte and JSON-escaped paths', () => { + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + const events = multiByteWatcherBatch(5000) + + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + expectMaximallyPackedChunks(capture, NODE_21_PRODUCER_CAPACITY) + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('fills the first chunk to exactly the producer capacity and admits it', () => { + const exact = batchEncodingTo(NODE_21_PRODUCER_CAPACITY) + expect(frameBytesFor(expectedPayloads(exact))).toBe(NODE_21_PRODUCER_CAPACITY) + const events = [...exact, ...watcherBatch(50)] + + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + expect(capture.frames).toHaveLength(2) + expect(capture.frames[0].length).toBe(NODE_21_PRODUCER_CAPACITY) + expect(capture.framedEvents()[0]).toEqual(expectedPayloads(exact)) + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('splits one byte past the producer capacity into two admitted frames', () => { + const overshoot = batchEncodingTo(NODE_21_PRODUCER_CAPACITY + 1) + expect(frameBytesFor(expectedPayloads(overshoot))).toBe(NODE_21_PRODUCER_CAPACITY + 1) + + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + try { + emitRelayWatcherEvents(dispatcher, false, overshoot) + + expect(detached).toEqual([]) + expect(capture.frames).toHaveLength(2) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(overshoot)) + } finally { + dispatcher.dispose() + } + }) + + it('sizes chunks against the smallest capacity across attached clients', () => { + const { capture: attachedCapture, write: attachedWrite } = captureFrames() + const { dispatcher, capture, detached } = createDispatcher(NODE_22_HIGH_WATER_MARK) + const events = watcherBatch(2000) + + try { + dispatcher.attachClient(attachedWrite, { + writableHighWaterMark: () => NODE_21_HIGH_WATER_MARK, + writableLength: () => 0 + }) + + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([]) + for (const frame of [...capture.frames, ...attachedCapture.frames]) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) + expect(attachedCapture.fsChangedEvents()).toEqual(expectedPayloads(events)) + } finally { + dispatcher.dispose() + } + }) + + it('still closes the client when a single event cannot fit any frame', () => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + const oversized: WatcherProcessEvent[] = [ + { type: 'update', path: `/workspace/${'p'.repeat(20_000)}.txt`, isDirectory: false } + ] + + try { + emitRelayWatcherEvents(dispatcher, false, oversized) + + expect(detached).toEqual([1]) + expect(capture.frames).toEqual([]) + } finally { + dispatcher.dispose() + } + }) + + it('delivers the prefix then closes when an oversized event appears mid-batch', () => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) + const oversized = { + type: 'update', + path: `/workspace/${'p'.repeat(20_000)}.txt`, + isDirectory: false + } as WatcherProcessEvent + const events = [...watcherBatch(300), oversized, ...watcherBatch(50)] + + try { + emitRelayWatcherEvents(dispatcher, false, events) + + expect(detached).toEqual([1]) + const delivered = capture.fsChangedEvents() + expect(delivered.length).toBeGreaterThan(0) + expect(delivered).toEqual(expectedPayloads(events).slice(0, delivered.length)) + expect(delivered.some((event) => event.absolutePath === oversized.path)).toBe(false) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + } finally { + dispatcher.dispose() + } + }) + + it('keeps the fail-closed kill when retained chunks exhaust the producer queue budget', () => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + // An unsettled sink models retained relay stdout writes. + const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK, { + supportsWriteCallback: true + }) + const emitted: WatcherProcessEvent[] = [] + + try { + for (let round = 0; round < 10 && detached.length === 0; round++) { + const events = watcherBatch(5000) + emitted.push(...events) + emitRelayWatcherEvents(dispatcher, false, events) + } + + expect(detached).toEqual([1]) + const delivered = capture.fsChangedEvents() + expect(delivered.length).toBeGreaterThan(0) + expect(delivered.length).toBeLessThan(emitted.length) + // The kill truncates delivery at a chunk boundary; nothing is reordered or duplicated. + expect(delivered).toEqual(expectedPayloads(emitted).slice(0, delivered.length)) + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) + } + } finally { + dispatcher.dispose() + } + }) + + it('does not notify at all when no client is attached', () => { + const { capture, write } = captureFrames() + const dispatcher = new RelayDispatcher(write, { + writableHighWaterMark: () => NODE_21_HIGH_WATER_MARK, + writableLength: () => 0 + }) + const notify = vi.spyOn(dispatcher, 'notify') + try { + dispatcher.invalidateClient() + emitRelayWatcherEvents(dispatcher, false, watcherBatch(10)) + + expect(notify).not.toHaveBeenCalled() + expect(capture.frames).toEqual([]) + } finally { + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/relay-watcher-parent-removal.test.ts b/src/relay/relay-watcher-parent-removal.test.ts index 892759204e0..c7736d61102 100644 --- a/src/relay/relay-watcher-parent-removal.test.ts +++ b/src/relay/relay-watcher-parent-removal.test.ts @@ -27,6 +27,16 @@ function context(clientId: number): RequestContext { return { clientId, isStale: () => false } } +function watchDispatcher() { + return { + notify: vi.fn(), + notifyClient: vi.fn(), + onClientDetached: vi.fn(), + broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), + notificationFrameBytes: vi.fn(() => 64) + } +} + describe('RelayFilesystemWatchRegistry parent removal', () => { it.each([ ['/repo', '/repo/nested', '/repo-sibling'], @@ -35,7 +45,7 @@ describe('RelayFilesystemWatchRegistry parent removal', () => { 'closes descendant watches before deleting parent %s and preserves siblings', async (parent, descendant, sibling) => { const pool = new ParentRemovalPool() - const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const dispatcher = watchDispatcher() const registry = new RelayFilesystemWatchRegistry( dispatcher as unknown as RelayDispatcher, pool @@ -60,7 +70,7 @@ describe('RelayFilesystemWatchRegistry parent removal', () => { it('waits for a descendant setup and closes its published subscription before removal', async () => { const pool = new ParentRemovalPool() - const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const dispatcher = watchDispatcher() const registry = new RelayFilesystemWatchRegistry( dispatcher as unknown as RelayDispatcher, pool @@ -90,7 +100,7 @@ describe('RelayFilesystemWatchRegistry parent removal', () => { it('waits for an already-retiring descendant before removal', async () => { const pool = new ParentRemovalPool() - const dispatcher = { notify: vi.fn(), notifyClient: vi.fn(), onClientDetached: vi.fn() } + const dispatcher = watchDispatcher() const registry = new RelayFilesystemWatchRegistry( dispatcher as unknown as RelayDispatcher, pool diff --git a/src/relay/relay-watcher-pending-setup-waiters.test.ts b/src/relay/relay-watcher-pending-setup-waiters.test.ts index f3e6f8f897d..e2bacc3801e 100644 --- a/src/relay/relay-watcher-pending-setup-waiters.test.ts +++ b/src/relay/relay-watcher-pending-setup-waiters.test.ts @@ -23,7 +23,12 @@ function context(clientId: number, signal?: AbortSignal): RequestContext { describe('RelayFilesystemWatchRegistry pending setup waiters', () => { it('removes ten thousand aborted callers queued behind one teardown anchor', async () => { const pool = new PendingSetupPool() - const dispatcher = { notify: vi.fn(), onClientDetached: vi.fn() } + const dispatcher = { + notify: vi.fn(), + onClientDetached: vi.fn(), + broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), + notificationFrameBytes: vi.fn(() => 64) + } const registry = new RelayFilesystemWatchRegistry( dispatcher as unknown as RelayDispatcher, pool From c79b859758098ac602cf319855c6b25e77367e4e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:08:11 -0700 Subject: [PATCH 3/9] fix(browser): prevent window.close guest crashes (#11910) * fix(browser): prevent window.close guest crashes * fix(browser): guard close before inline scripts * fix(browser): preserve explicit window close policy --- electron.vite.config.ts | 2 + src/main/browser/browser-manager.test.ts | 32 ++++ src/main/browser/browser-manager.ts | 9 +- src/main/window/createMainWindow.test.ts | 33 ++++ src/main/window/createMainWindow.ts | 17 +- .../browser-window-close-installation.ts | 15 ++ src/preload/browser-window-close.test.ts | 20 +++ src/preload/browser-window-close.ts | 7 + .../components/browser-pane/BrowserPane.tsx | 1 + .../BrowserPane.webview-preferences.test.ts | 19 ++ .../browser-pane/browser-page-webview.ts | 7 + src/renderer/src/hooks/useIpcEvents.test.ts | 5 + src/renderer/src/hooks/useIpcEvents.ts | 1 + src/renderer/src/store/slices/browser.ts | 17 +- src/shared/browser-window-close-policy.ts | 2 + src/shared/types.ts | 2 + src/shared/workspace-session-schema.ts | 1 + tests/e2e/browser-tab.spec.ts | 162 +++++++++++++++++- 18 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 src/preload/browser-window-close-installation.ts create mode 100644 src/preload/browser-window-close.test.ts create mode 100644 src/preload/browser-window-close.ts create mode 100644 src/shared/browser-window-close-policy.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 02239421ae9..39f923cb215 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -206,6 +206,8 @@ export const electronViteConfig: UserConfig = { external: isExternalMainModule, input: { index: resolve('src/main/index.ts'), + // Why: sandboxed webview preloads cannot load Rollup helper chunks. + 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 286f172a645..b93a0358537 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -1512,6 +1512,38 @@ describe('browserManager', () => { expect(browserManager.getGuestWebContentsId('browser-destroyed-before-register')).toBeNull() }) + it('removes a destroyed primary guest from its tab registration maps', () => { + const guest = { + id: 306, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-destroyed-after-register', + webContentsId: guest.id, + rendererWebContentsId + }) + + const destroyedHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + destroyedHandler?.() + + expect(browserManager.getGuestWebContentsId('browser-destroyed-after-register')).toBeNull() + const managerState = browserManager as unknown as { + tabIdByWebContentsId: Map + } + expect(managerState.tabIdByWebContentsId.has(guest.id)).toBe(false) + }) + it('fully unregisters stale guests discovered during authorization', () => { const guest = { id: 305, diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 7dafca617f2..66f6ac653bf 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -640,7 +640,6 @@ export class BrowserManager { // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. const disposeAntiDetection = this.injectAntiDetection(guest) - // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. guest.setBackgroundThrottling(false) const installClickedLinkRouting = (): void => { @@ -940,11 +939,15 @@ export class BrowserManager { private retireStaleGuestWebContents(previousWebContentsId: number): void { // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. this.cleanupGuestPolicyAttachment(previousWebContentsId) - this.tabIdByWebContentsId.delete(previousWebContentsId) } private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { - const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId) + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + const isPrimaryGuest = browserTabId !== undefined + if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { + this.webContentsIdByTabId.delete(browserTabId) + } + this.tabIdByWebContentsId.delete(guestWebContentsId) this.certificateTrustController?.onGuestRetired(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index fc38ca95e77..8a61f157584 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -74,6 +74,8 @@ import { } from './createMainWindow' import { ipcMain } from 'electron' import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy' +import { fileURLToPath } from 'node:url' function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = process.platform @@ -248,6 +250,7 @@ describe('createMainWindow', () => { expect(allowBlankPrefs).toMatchObject({ disableHtmlFullscreenWindowResize: true, partition: 'persist:orca-browser', + preload: expect.stringMatching(/browser-window-close-preload\.js$/), sandbox: true }) @@ -262,6 +265,36 @@ describe('createMainWindow', () => { const guest = { marker: 'guest' } windowHandlers['did-attach-webview']({} as never, guest as never) expect(attachGuestPoliciesMock).toHaveBeenCalledWith(guest) + + const allowWindowCloseEvent = { preventDefault: vi.fn() } + const allowWindowCloseParams = { + src: 'data:text/html,', + preload: BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD + } + const allowWindowClosePrefs = { partition: 'persist:orca-browser' } + windowHandlers['will-attach-webview']( + allowWindowCloseEvent as never, + allowWindowClosePrefs as never, + allowWindowCloseParams as never + ) + expect(allowWindowCloseEvent.preventDefault).not.toHaveBeenCalled() + expect(allowWindowCloseParams.preload).toBeUndefined() + expect(allowWindowClosePrefs).not.toHaveProperty('preload') + + const allowWindowClosePathPrefs = { + partition: 'persist:orca-browser', + preload: fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) + } + windowHandlers['will-attach-webview']( + { preventDefault: vi.fn() } as never, + allowWindowClosePathPrefs as never, + { src: 'data:text/html,', preload: '' } as never + ) + expect(allowWindowClosePathPrefs).not.toHaveProperty('preload') + + const cliGuest = { marker: 'cli-guest' } + windowHandlers['did-attach-webview']({} as never, cliGuest as never) + expect(attachGuestPoliciesMock).toHaveBeenLastCalledWith(cliGuest) }) it('sets platform-specific titlebar and frame options for every desktop platform', () => { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 519e1817051..49c3eb4c125 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -10,6 +10,7 @@ import { screen } from 'electron' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' import { getAppIconPath } from '../app-icon' @@ -18,6 +19,7 @@ import { browserSessionRegistry } from '../browser/browser-session-registry' import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy' import { isCrashReportReason } from '../../shared/crash-reporting' import { DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, @@ -441,6 +443,8 @@ export function createMainWindow( // so register it with the window's other navigation policy. registerPluginPanelNavigationGuard(mainWindow.webContents) + const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js') + const browserWindowCloseAllowedPreloadPath = fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' const normalizedSrc = normalizeBrowserNavigationUrl(src) @@ -452,7 +456,18 @@ export function createMainWindow( return } - delete webPreferences.preload + const allowWindowClose = [params.preload, webPreferences.preload].some( + (preload) => + preload === BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD || + preload === browserWindowCloseAllowedPreloadPath + ) + delete params.preload + if (allowWindowClose) { + delete webPreferences.preload + } else { + // Why: preload runs in the page's main world before inline scripts can call window.close(). + webPreferences.preload = browserWindowClosePreload + } // Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge. delete (webPreferences as Record).preloadURL webPreferences.nodeIntegration = false diff --git a/src/preload/browser-window-close-installation.ts b/src/preload/browser-window-close-installation.ts new file mode 100644 index 00000000000..adf2fdb09bf --- /dev/null +++ b/src/preload/browser-window-close-installation.ts @@ -0,0 +1,15 @@ +export function installBrowserWindowCloseGuard(): void { + const ignoreWindowClose = (): void => {} + try { + Object.defineProperty(window, 'close', { + configurable: false, + enumerable: false, + writable: false, + value: ignoreWindowClose + }) + } catch { + try { + window.close = ignoreWindowClose + } catch {} + } +} diff --git a/src/preload/browser-window-close.test.ts b/src/preload/browser-window-close.test.ts new file mode 100644 index 00000000000..1b35a45f8df --- /dev/null +++ b/src/preload/browser-window-close.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { installBrowserWindowCloseGuard } from './browser-window-close-installation' + +describe('browser window close preload', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('installs a non-replaceable window.close no-op in the page world', () => { + const nativeClose = vi.fn() + vi.stubGlobal('window', { close: nativeClose }) + + installBrowserWindowCloseGuard() + + expect(window.close()).toBeUndefined() + expect(window.close).not.toBe(nativeClose) + expect(Reflect.set(window, 'close', nativeClose)).toBe(false) + }) +}) diff --git a/src/preload/browser-window-close.ts b/src/preload/browser-window-close.ts new file mode 100644 index 00000000000..7fb8201377f --- /dev/null +++ b/src/preload/browser-window-close.ts @@ -0,0 +1,7 @@ +import { installBrowserWindowCloseGuard } from './browser-window-close-installation' +import type { ContextBridge } from 'electron' + +// Why: raw require keeps the sandboxed preload standalone in the main-process CJS build. +const { contextBridge } = require('electron') as { contextBridge: ContextBridge } + +contextBridge.executeInMainWorld({ func: installBrowserWindowCloseGuard }) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 1a2c0279543..60cc98d8d7c 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -3693,6 +3693,7 @@ function BrowserPagePane({ container, inputLocked: inputLockedRef.current, webviewPartition, + allowWindowClose: browserTab.allowWindowClose === true, resolveContainer: () => ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null }) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts index 203fce6fdbf..496b23ec409 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts +++ b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy' const registryMocks = vi.hoisted(() => ({ destroyPersistentWebview: vi.fn(), @@ -103,4 +104,22 @@ describe('BrowserPane webview preferences', () => { expect(ensuredWebview?.webview.style.pointerEvents).toBe('none') expect(refreshedContainer.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element) }) + + it('marks explicitly allowed CLI pages before the guest attaches', () => { + const container = createContainer('cli-page') + + const ensuredWebview = ensureBrowserPageWebview({ + browserTabId: 'browser-page-cli', + container, + inputLocked: false, + webviewPartition: 'persist:orca-browser', + allowWindowClose: true, + resolveContainer: () => container + }) + + expect(ensuredWebview?.webview.getAttribute('preload')).toBe( + BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD + ) + expect(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD).toMatch(/^file:/) + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-page-webview.ts b/src/renderer/src/components/browser-pane/browser-page-webview.ts index 1179d0da63b..b8c44d849ad 100644 --- a/src/renderer/src/components/browser-pane/browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/browser-page-webview.ts @@ -1,4 +1,5 @@ import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' +import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy' import { destroyPersistentWebview, registerPersistentWebview, @@ -10,12 +11,14 @@ export function ensureBrowserPageWebview({ container, inputLocked, webviewPartition, + allowWindowClose, resolveContainer }: { browserTabId: string container: HTMLDivElement inputLocked: boolean webviewPartition: string + allowWindowClose?: boolean resolveContainer: () => HTMLDivElement | null }): { container: HTMLDivElement; created: boolean; webview: Electron.WebviewTag } | null { let webview = webviewRegistry.get(browserTabId) @@ -46,6 +49,10 @@ export function ensureBrowserPageWebview({ webview = document.createElement('webview') as Electron.WebviewTag webview.setAttribute('partition', webviewPartition) + if (allowWindowClose) { + // Why: main consumes and removes this marker in will-attach-webview before the guest sees any preload. + webview.setAttribute('preload', BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) + } webview.setAttribute('allowpopups', '') // Why: Electron spreads the webpreferences keys verbatim, so the shared // camelCase attribute must stay intact for fullscreen containment to work. diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 1d956f716cb..0b7ea6ad0af 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1077,6 +1077,11 @@ describe('useIpcEvents browser tab create routing', () => { expect(acquireBrowserAutomationVisibility).toHaveBeenCalledWith('page-new') expect(acquireBrowserAutomationVisibility).not.toHaveBeenCalledWith('page-active') + expect(state.createBrowserTab).toHaveBeenCalledWith( + 'wt-1', + 'https://example.com', + expect.objectContaining({ allowWindowClose: true }) + ) expect(replyTabCreate).toHaveBeenCalledWith({ requestId: 'req-create', browserPageId: 'page-new' diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 3c8b19068c2..2a72aae4918 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2266,6 +2266,7 @@ export function useIpcEvents(): void { // Agent/automation opens stay in the background (activate:false) in the active browser group. const workspace = store.createBrowserTab(worktreeId, data.url, { title: data.url, + allowWindowClose: true, targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, sessionProfileId: data.sessionProfileId, sessionPartition: data.sessionPartition, diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 60a71a3eb47..42d0ddbce7b 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -57,6 +57,7 @@ import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktr type CreateBrowserTabOptions = { activate?: boolean title?: string + allowWindowClose?: boolean sessionProfileId?: string | null sessionPartition?: string | null // Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group. @@ -69,6 +70,7 @@ type CreateBrowserTabOptions = { type CreateBrowserPageOptions = { activate?: boolean title?: string + allowWindowClose?: boolean browserRuntimeEnvironmentId?: string | null } @@ -341,7 +343,8 @@ function buildBrowserPage( worktreeId: string, url: string, title?: string, - browserRuntimeEnvironmentId?: string | null + browserRuntimeEnvironmentId?: string | null, + allowWindowClose?: boolean ): BrowserPage { const normalizedUrl = normalizeUrl(url) return { @@ -357,6 +360,7 @@ function buildBrowserPage( canGoForward: false, loadError: null, createdAt: Date.now(), + ...(allowWindowClose !== undefined ? { allowWindowClose } : {}), ...(browserRuntimeEnvironmentId !== undefined ? { browserRuntimeEnvironmentId } : {}) } } @@ -554,7 +558,8 @@ export const createBrowserSlice: StateCreator = worktreeId, url, options?.title, - options?.browserRuntimeEnvironmentId + options?.browserRuntimeEnvironmentId, + options?.allowWindowClose ) // Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs. const sessionProfileId = @@ -902,13 +907,15 @@ export const createBrowserSlice: StateCreator = activate: true, sessionProfileId, sessionPartition, - browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId + browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId, + allowWindowClose: firstPage.allowWindowClose }) for (const p of restPages) { get().createBrowserPage(restored.id, p.url, { activate: false, title: p.title, + allowWindowClose: p.allowWindowClose, browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId }) } @@ -987,7 +994,8 @@ export const createBrowserSlice: StateCreator = workspace.worktreeId, url, options?.title, - options?.browserRuntimeEnvironmentId + options?.browserRuntimeEnvironmentId, + options?.allowWindowClose ) set((s) => { @@ -1169,6 +1177,7 @@ export const createBrowserSlice: StateCreator = return get().createBrowserPage(workspaceId, pageToRestore.url, { title: pageToRestore.title, activate: true, + allowWindowClose: pageToRestore.allowWindowClose, browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId }) }, diff --git a/src/shared/browser-window-close-policy.ts b/src/shared/browser-window-close-policy.ts new file mode 100644 index 00000000000..2ea28169ee7 --- /dev/null +++ b/src/shared/browser-window-close-policy.ts @@ -0,0 +1,2 @@ +// Why: this marker carries the CLI's explicit close policy across attach before the guest exists. +export const BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD = 'file:///__orca_window_close_allowed__' diff --git a/src/shared/types.ts b/src/shared/types.ts index 475470dd4c4..a03c2420985 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -982,6 +982,8 @@ export type BrowserPage = { canGoForward: boolean loadError: BrowserLoadError | null createdAt: number + // Why: CLI-created pages retain Chromium's native window.close behavior; ordinary embedded pages are guarded. + allowWindowClose?: boolean // Why: remote-owned worktrees can still host client-local fallback browser // pages until headless remote runtimes support real browser panes. browserRuntimeEnvironmentId?: string | null diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index da5fe4ef08e..f5d849d5270 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -225,6 +225,7 @@ const browserPageSchema = z.object({ canGoForward: z.boolean(), loadError: browserLoadErrorSchema.nullable(), createdAt: z.number(), + allowWindowClose: z.boolean().optional(), // Why: explicit null marks a browser page as client-local even when its // worktree is remote-owned; older sessions omit it and keep inferred runtime. browserRuntimeEnvironmentId: z.string().nullable().optional(), diff --git a/tests/e2e/browser-tab.spec.ts b/tests/e2e/browser-tab.spec.ts index 7ac10558047..bc8dd51e988 100644 --- a/tests/e2e/browser-tab.spec.ts +++ b/tests/e2e/browser-tab.spec.ts @@ -29,10 +29,11 @@ async function createBrowserTab( page: Parameters[0], worktreeId: string, url?: string, - title = 'New Browser Tab' + title = 'New Browser Tab', + allowWindowClose?: boolean ): Promise { return page.evaluate( - ({ targetWorktreeId, targetUrl, targetTitle }) => { + ({ targetWorktreeId, targetUrl, targetTitle, allowClose }) => { const store = window.__store if (!store) { return null @@ -44,12 +45,18 @@ async function createBrowserTab( targetUrl ?? state.browserDefaultUrl ?? 'about:blank', { title: targetTitle, - activate: true + activate: true, + allowWindowClose: allowClose } ) return { id: tab.id, pageId: tab.activePageId ?? null } }, - { targetWorktreeId: worktreeId, targetUrl: url, targetTitle: title } + { + targetWorktreeId: worktreeId, + targetUrl: url, + targetTitle: title, + allowClose: allowWindowClose + } ) } @@ -199,6 +206,71 @@ async function startBrowserLinkServer(): Promise<{ } } +async function startBrowserWindowCloseServer(): Promise<{ + url: string + sourceUrl: string + close: () => Promise +}> { + const server = createServer((request, response) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const pathname = new URL(request.url ?? '/', origin).pathname + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + if (pathname === '/source') { + response.end(` + + + Close link source + Open close page + + `) + return + } + response.end(` + + + Window close repro + +

Attempting close…

+ + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + url: `http://127.0.0.1:${port}/window-close`, + sourceUrl: `http://127.0.0.1:${port}/source`, + close: () => closeServer(server) + } +} + +async function readBrowserWindowCloseStatus( + page: Parameters[0], + browserTabId: string +): Promise { + return page.evaluate(async (targetBrowserTabId) => { + const slot = document.querySelector(`[data-browser-overlay-tab-id="${targetBrowserTabId}"]`) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return 'webview missing' + } + try { + return (await webview.executeJavaScript( + 'document.querySelector("#s")?.textContent ?? "status missing"' + )) as string + } catch { + return 'guest lost' + } + }, browserTabId) +} + async function closeServer(server: Server): Promise { await new Promise((resolve, reject) => server.close((error) => { @@ -689,6 +761,88 @@ test.describe('Browser Tab', () => { } }) + test('blocked window.close in a link-created tab does not break tab switching', async ({ + orcaPage + }) => { + const closeServer = await startBrowserWindowCloseServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const neighboringTab = await createBrowserTab( + orcaPage, + worktreeId, + 'about:blank', + 'Neighboring tab' + ) + const sourceTab = await createBrowserTab( + orcaPage, + worktreeId, + closeServer.sourceUrl, + 'Close link source' + ) + expect(neighboringTab?.id).toBeTruthy() + expect(sourceTab?.id).toBeTruthy() + + await clickBrowserLink(orcaPage, sourceTab!.id, '#window-close-link') + let closeTabId: string | null = null + await expect + .poll(async () => { + const tabs = await getBrowserTabs(orcaPage, worktreeId) + closeTabId = tabs.find((tab) => tab.url === closeServer.url)?.id ?? null + return closeTabId + }) + .not.toBeNull() + + await orcaPage.locator(`[data-tab-id="${neighboringTab!.id}"]`).click() + await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 5_000 }).toBe('browser') + await expect + .poll(() => readBrowserWindowCloseStatus(orcaPage, closeTabId!), { timeout: 5_000 }) + .toContain('window.close() was blocked') + } finally { + await closeServer.close() + } + }) + + test('explicitly allowed browser tabs retain replaceable window.close', async ({ orcaPage }) => { + const closeServer = await startBrowserWindowCloseServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const allowedTab = await createBrowserTab( + orcaPage, + worktreeId, + closeServer.sourceUrl, + 'Allowed close tab', + true + ) + expect(allowedTab?.id).toBeTruthy() + + await expect + .poll( + () => + orcaPage.evaluate(async (targetBrowserTabId) => { + const slot = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return 'webview missing' + } + try { + return (await webview.executeJavaScript(`(() => { + window.close = () => 'replacement-called' + return window.close() === 'replacement-called' ? 'replacement-called' : 'blocked' + })()`)) as string + } catch { + return 'guest unavailable' + } + }, allowedTab!.id), + { timeout: 5_000 } + ) + .toBe('replacement-called') + } finally { + await closeServer.close() + } + }) + /** * User Prompt: * - Browser works and also retains state when switching tabs etc. From 76a2317bc0a057a9c4edb910da3db7009d8d316b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:22:36 -0700 Subject: [PATCH 4/9] fix(persistence): stop blocking backup rotation (#11916) Use async profile probes and serialize the current-time due decision plus mutations under one async owner so sync flushes and detached writers cannot double-shift or miss the recovery interval. Add syscall, timer-liveness, parity, and held-I/O interleaving coverage. --- .../persistence-async-write-syscalls.test.ts | 496 ++++++++++++++++++ src/main/persistence.ts | 82 ++- 2 files changed, 554 insertions(+), 24 deletions(-) create mode 100644 src/main/persistence-async-write-syscalls.test.ts diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts new file mode 100644 index 00000000000..6a4491e8b38 --- /dev/null +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -0,0 +1,496 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const testState = { dir: '' } + +const fsCalls = vi.hoisted(() => { + const blocker = new Int32Array(new SharedArrayBuffer(4)) + const calls = { + recording: false, + dirPrefix: '', + /** When > 0, every recorded sync call parks the main thread this long — a stalled mount in miniature. */ + stallMs: 0, + syncCalls: [] as string[], + asyncCalls: [] as string[], + failAsync: null as ((fn: string, target: string) => NodeJS.ErrnoException | null) | null, + beforeAsync: null as ((fn: string, target: string) => void) | null, + waitAsync: null as ((fn: string, target: string) => Promise | null) | null, + reset(): void { + calls.syncCalls.length = 0 + calls.asyncCalls.length = 0 + calls.stallMs = 0 + calls.failAsync = null + calls.beforeAsync = null + calls.waitAsync = null + }, + inScope(target: unknown): target is string { + return ( + calls.recording && + typeof target === 'string' && + calls.dirPrefix !== '' && + target.startsWith(calls.dirPrefix) + ) + }, + recordSync(fn: string, target: unknown): void { + if (!calls.inScope(target)) { + return + } + calls.syncCalls.push(`${fn}:${target}`) + if (calls.stallMs > 0) { + // Atomics.wait blocks the thread the way an uninterruptible syscall does. + Atomics.wait(blocker, 0, 0, calls.stallMs) + } + }, + recordAsync(fn: string, target: unknown): NodeJS.ErrnoException | null { + if (!calls.inScope(target)) { + return null + } + calls.asyncCalls.push(`${fn}:${target}`) + calls.beforeAsync?.(fn, target) + return calls.failAsync?.(fn, target) ?? null + } + } + return calls +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const patched: Record = { ...actual } + for (const name of Object.keys(actual)) { + const original = (actual as unknown as Record)[name] + if (!name.endsWith('Sync') || typeof original !== 'function') { + continue + } + const fn = original as (...args: unknown[]) => unknown + const wrapper = (...args: unknown[]): unknown => { + fsCalls.recordSync(name, args[0]) + return fn(...args) + } + patched[name] = Object.assign(wrapper, fn) + } + return { ...patched, default: patched } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + const patched: Record = { ...actual } + for (const name of ['stat', 'access', 'rename', 'copyFile', 'rm', 'mkdir', 'open']) { + const fn = (actual as unknown as Record unknown>)[name] + patched[name] = async (...args: unknown[]): Promise => { + const failure = fsCalls.recordAsync(name, args[0]) + if (failure) { + throw failure + } + if (typeof args[0] === 'string') { + await fsCalls.waitAsync?.(name, args[0]) + } + return fn(...args) + } + } + return { ...patched, default: patched } +}) + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +// Deterministic cipher so two stores driven through identical mutations produce identical bytes. +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) + } +})) + +const BACKUP_COUNT = 5 +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 +const PAST_ROTATION_INTERVAL_MS = BACKUP_MIN_INTERVAL_MS * 2 +const SAVE_DEBOUNCE_MS = 1_000 + +const ROTATION_INTERLEAVE_CASES = [ + ['initial access', 'access', ''], + ['oldest removal', 'rm', '.bak.4'], + ['slot access', 'access', '.bak.0'], + ['slot rename', 'rename', '.bak.0'], + ['final copy', 'copyFile', ''] +] as const + +type TestStore = { + updateUI(updates: { sidebarWidth: number }): void + waitForPendingWrite(): Promise + flushOrThrow(): void +} + +async function createStore(dir: string): Promise { + testState.dir = dir + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() as unknown as TestStore +} + +function dataFile(dir: string): string { + return join(dir, 'orca-data.json') +} + +function seedStaleBackup(dir: string): void { + const path = `${dataFile(dir)}.bak.0` + writeFileSync(path, '{"stale":true}', 'utf-8') + const staleSeconds = (Date.now() - PAST_ROTATION_INTERVAL_MS) / 1000 + utimesSync(path, staleSeconds, staleSeconds) +} + +function ringSnapshot(dir: string): Record { + const snapshot: Record = {} + for (const name of readdirSync(dir).sort()) { + if (name === 'orca-data.json' || name.startsWith('orca-data.json.bak.')) { + snapshot[name] = readFileSync(join(dir, name), 'utf-8') + } + } + return snapshot +} + +describe('async persistence write path avoids synchronous fs syscalls', () => { + const dirs: string[] = [] + + function makeDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-async-write-')) + dirs.push(dir) + return dir + } + + beforeEach(() => { + fsCalls.recording = false + fsCalls.dirPrefix = '' + fsCalls.reset() + vi.useFakeTimers() + }) + + afterEach(() => { + fsCalls.recording = false + vi.useRealTimers() + while (dirs.length > 0) { + rmSync(dirs.pop() as string, { recursive: true, force: true }) + } + }) + + async function recordAsyncSave( + store: TestStore, + dir: string, + sidebarWidth: number + ): Promise { + store.updateUI({ sidebarWidth }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + await store.waitForPendingWrite() + } finally { + fsCalls.recording = false + } + } + + it('issues no sync fs syscall under the profile dir when rotation runs with an empty ring', async () => { + const dir = makeDir() + const store = await createStore(dir) + + await recordAsyncSave(store, dir, 301) + + expect(fsCalls.syncCalls).toEqual([]) + // Rotation must actually have happened, else the assertion above is vacuous. + expect(ringSnapshot(dir)['orca-data.json.bak.0']).toBe(readFileSync(dataFile(dir), 'utf-8')) + }) + + it('uses an awaited stat, not statSync, for the backup rotation interval check', async () => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + + await recordAsyncSave(store, dir, 302) + + expect(fsCalls.syncCalls).toEqual([]) + expect(fsCalls.asyncCalls).toContain(`stat:${dataFile(dir)}.bak.0`) + }) + + it('issues no sync fs syscall while rotating a saturated ring', async () => { + const dir = makeDir() + const store = await createStore(dir) + + // One more save than the ring holds, so the oldest slot is evicted and every slot renames. + for (let i = 0; i <= BACKUP_COUNT; i++) { + await recordAsyncSave(store, dir, 310 + i) + } + + expect(fsCalls.syncCalls).toEqual([]) + const ring = ringSnapshot(dir) + expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`) + expect(Object.keys(ring)).not.toContain(`orca-data.json.bak.${BACKUP_COUNT}`) + }) + + it('lets a main-thread timer keep firing while a rotating save is in flight', async () => { + // A recorded sync call stalls long enough for the heartbeat to catch it. + vi.useRealTimers() + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + // Generous stall so ordinary scheduler/GC jitter can't reach the threshold on a loaded CI box. + const stallMs = 500 + + let lastTick = Date.now() + let worstGapMs = 0 + const heartbeat = setInterval(() => { + const now = Date.now() + worstGapMs = Math.max(worstGapMs, now - lastTick) + lastTick = now + }, 10) + + try { + store.updateUI({ sidebarWidth: 341 }) + fsCalls.dirPrefix = dir + fsCalls.stallMs = stallMs + fsCalls.recording = true + lastTick = Date.now() + await new Promise((resolve) => setTimeout(resolve, SAVE_DEBOUNCE_MS + 200)) + await store.waitForPendingWrite() + } finally { + fsCalls.recording = false + clearInterval(heartbeat) + } + + expect(worstGapMs).toBeLessThan(stallMs) + // The save really happened, so a small gap isn't just an absent write. + expect(ringSnapshot(dir)['orca-data.json.bak.0']).toBe(readFileSync(dataFile(dir), 'utf-8')) + }, 20_000) + + it('skips rotation when a sync flush rotated during the rotation-interval await', async () => { + // The acquired owner keeps rotation when the flush skips the ring. + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') + + store.updateUI({ sidebarWidth: 361 }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + let flushed = false + fsCalls.beforeAsync = (fn, target) => { + if (flushed || fn !== 'stat' || !target.endsWith('.bak.0')) { + return + } + flushed = true + store.updateUI({ sidebarWidth: 362 }) + store.flushOrThrow() + } + try { + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + await store.waitForPendingWrite() + } finally { + fsCalls.recording = false + fsCalls.beforeAsync = null + } + + expect(flushed).toBe(true) + const ring = ringSnapshot(dir) + expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) + expect(ring['orca-data.json.bak.1']).toBe(staleBackup) + expect(ring['orca-data.json.bak.2']).toBeUndefined() + }) + + it('keeps the due check inside ownership when a second writer starts', async () => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + const almostDueSeconds = (Date.now() - (BACKUP_MIN_INTERVAL_MS - SAVE_DEBOUNCE_MS - 100)) / 1000 + utimesSync(`${dataFile(dir)}.bak.0`, almostDueSeconds, almostDueSeconds) + const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') + const statCall = `stat:${dataFile(dir)}.bak.0` + let releaseRotation!: () => void + const rotationRelease = new Promise((resolve) => { + releaseRotation = resolve + }) + let signalRotation!: () => void + const rotationStarted = new Promise((resolve) => { + signalRotation = resolve + }) + let held = false + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'stat' || target !== `${dataFile(dir)}.bak.0`) { + return null + } + held = true + signalRotation() + return rotationRelease + } + + fsCalls.dirPrefix = dir + fsCalls.recording = true + store.updateUI({ sidebarWidth: 371 }) + vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) + const firstWrite = store.waitForPendingWrite() + try { + await rotationStarted + store.updateUI({ sidebarWidth: 372 }) + store.flushOrThrow() + store.updateUI({ sidebarWidth: 373 }) + vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) + await store.waitForPendingWrite() + expect(fsCalls.asyncCalls.filter((call) => call === statCall)).toHaveLength(1) + } finally { + releaseRotation() + await firstWrite + fsCalls.recording = false + fsCalls.waitAsync = null + } + const ring = ringSnapshot(dir) + expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) + expect(ring['orca-data.json.bak.1']).toBe(staleBackup) + expect(ring['orca-data.json.bak.2']).toBeUndefined() + }) + + it.each(ROTATION_INTERLEAVE_CASES)( + 'keeps one rotation owner when a sync flush lands during %s', + async (_phase, expectedFn, targetSuffix) => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8') + const expectedTarget = `${dataFile(dir)}${targetSuffix}` + + store.updateUI({ sidebarWidth: 363 }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + let flushed = false + fsCalls.beforeAsync = (fn, target) => { + if (flushed || fn !== expectedFn || target !== expectedTarget) { + return + } + flushed = true + store.updateUI({ sidebarWidth: 364 }) + store.flushOrThrow() + } + try { + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + await store.waitForPendingWrite() + } finally { + fsCalls.recording = false + fsCalls.beforeAsync = null + } + + expect(flushed).toBe(true) + const ring = ringSnapshot(dir) + expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) + expect(ring['orca-data.json.bak.1']).toBe(staleBackup) + expect(ring['orca-data.json.bak.2']).toBeUndefined() + } + ) + + it('does not log for absent ring slots even when the mount rejects renames non-ENOENT', async () => { + // Probing keeps degraded mounts from logging one error per absent slot. + const dir = makeDir() + const store = await createStore(dir) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + fsCalls.failAsync = (fn, target) => + fn === 'rename' && target.includes('.bak.') + ? Object.assign(new Error('stale NFS file handle'), { code: 'ESTALE' }) + : null + + try { + await recordAsyncSave(store, dir, 351) + expect(errors).not.toHaveBeenCalled() + } finally { + errors.mockRestore() + } + }) + + it('logs when a ring slot that exists fails to rotate', async () => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + fsCalls.failAsync = (fn, target) => + fn === 'rename' && target.endsWith('.bak.0') + ? Object.assign(new Error('permission denied'), { code: 'EPERM' }) + : null + + try { + await recordAsyncSave(store, dir, 352) + expect(errors).toHaveBeenCalledWith( + '[persistence] Failed to rotate backup', + `${dataFile(dir)}.bak.0`, + '->', + `${dataFile(dir)}.bak.1`, + expect.objectContaining({ code: 'EPERM' }) + ) + } finally { + errors.mockRestore() + } + }) + + it('produces a .bak ring byte-identical to the sync path, at capacity and with ring holes', async () => { + const asyncDir = makeDir() + const syncDir = makeDir() + // Match generated IDs so only rotation behavior can differ. + const seedDir = makeDir() + const seedStore = await createStore(seedDir) + seedStore.updateUI({ sidebarWidth: 320 }) + seedStore.flushOrThrow() + const seed = readFileSync(dataFile(seedDir), 'utf-8') + for (const dir of [asyncDir, syncDir]) { + writeFileSync(dataFile(dir), seed, 'utf-8') + // Holes: slots 1 and 3 occupied, 0 and 2 missing — exercises the per-slot existence branch. + writeFileSync(`${dataFile(dir)}.bak.1`, '{"old":1}', 'utf-8') + writeFileSync(`${dataFile(dir)}.bak.3`, '{"old":3}', 'utf-8') + } + + const widths = [321, 322, 323, 324, 325, 326] + const asyncStore = await createStore(asyncDir) + for (const width of widths) { + asyncStore.updateUI({ sidebarWidth: width }) + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + await asyncStore.waitForPendingWrite() + } + + const syncStore = await createStore(syncDir) + for (const width of widths) { + syncStore.updateUI({ sidebarWidth: width }) + syncStore.flushOrThrow() + vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS) + } + + const ring = ringSnapshot(asyncDir) + expect(ring).toEqual(ringSnapshot(syncDir)) + expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`) + }) + + it('keeps the sync quit/crash fallback on synchronous syscalls', async () => { + const dir = makeDir() + const store = await createStore(dir) + seedStaleBackup(dir) + + store.updateUI({ sidebarWidth: 331 }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + store.flushOrThrow() + } finally { + fsCalls.recording = false + } + + expect(fsCalls.syncCalls).toContain(`statSync:${dataFile(dir)}.bak.0`) + expect(fsCalls.syncCalls).toContain(`existsSync:${dataFile(dir)}`) + expect(fsCalls.asyncCalls).toEqual([]) + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index c5e4c2a81a9..e5744973f12 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -11,7 +11,7 @@ import { statSync, realpathSync } from 'node:fs' -import { rename, mkdir, rm, copyFile, open } from 'node:fs/promises' +import { rename, mkdir, rm, copyFile, open, stat, access } from 'node:fs/promises' import { renameDurable, writeFileDurableSync } from './durable-file-write' import { join, dirname, isAbsolute, resolve, sep } from 'node:path' import { homedir } from 'node:os' @@ -589,6 +589,14 @@ function backupPath(dataFile: string, index: number): string { return `${dataFile}.bak.${index}` } +/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false + ) +} + function buildWorkspaceDirHistoryForUpdate( current: GlobalSettings, updates: Partial @@ -2742,6 +2750,8 @@ export class Store { private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null private writeGeneration = 0 + // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. + private backupRotationInFlight = false // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. private writesFrozen = false // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. @@ -2884,28 +2894,52 @@ export class Store { } } + // Why separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible + // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. + private async shouldRotateBackupsAsync(dataFile: string): Promise { + try { + const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs + return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. private async rotateBackupsAsync(dataFile: string): Promise { - if (!existsSync(dataFile)) { + if (this.backupRotationInFlight) { return } - await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) + this.backupRotationInFlight = true + try { + if (!(await this.shouldRotateBackupsAsync(dataFile))) { + return } - }) - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - if (existsSync(src)) { - await rename(src, dst).catch((err) => { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - }) + if (!(await exists(dataFile))) { + return } + await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + }) + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an + // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. + if (await exists(src)) { + await rename(src, dst).catch((err) => { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + }) + } + } + await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + }) + } finally { + this.backupRotationInFlight = false } - await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - }) } private rotateBackupsSync(dataFile: string): void { @@ -3886,18 +3920,15 @@ export class Store { await rm(tmpFile).catch(() => {}) } } - // Why (issue #1158): rotate backups only after rename succeeded, and let a concurrent flush own rotation. + // Why (#1158): rotate only after the primary rename while this write still owns its generation. if (this.writeGeneration !== gen) { return } - const now = Date.now() - if (this.shouldRotateBackups(now, dataFile)) { - await this.rotateBackupsAsync(dataFile) - } + await this.rotateBackupsAsync(dataFile) } // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. - private writeToDiskSync(opts: { force?: boolean } = {}): void { + private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { if (this.writesFrozen) { return } @@ -3931,7 +3962,7 @@ export class Store { } } const now = Date.now() - if (this.shouldRotateBackups(now, dataFile)) { + if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { this.rotateBackupsSync(dataFile) } } @@ -3946,7 +3977,10 @@ export class Store { // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. this.writeGeneration++ this.pendingWrite = null - this.writeToDiskSync({ force: asyncWriteWasInFlight }) + this.writeToDiskSync({ + force: asyncWriteWasInFlight, + skipBackupRotation: this.backupRotationInFlight + }) } flushActiveViewPreferenceOrThrow(): void { From 1f307afa6d4efb3d23bacb3190e40ae1ad4a1cf7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:26:30 -0700 Subject: [PATCH 5/9] fix(terminal): preserve follow output through streaming refocus (#11915) --- config/reliability-gates.jsonc | 99 ++++++++ .../terminal-visibility-resume.test.ts | 44 ++++ .../terminal-visibility-resume.ts | 5 +- ...erminal-streaming-refocus-viewport.spec.ts | 240 ++++++++++++++++++ 4 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/terminal-streaming-refocus-viewport.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2785645d621..01db646c217 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3048,6 +3048,105 @@ ], "demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-scroll.streaming-refocus-intent", + "title": "Streaming refocus preserves follow-output viewport intent", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit-and-electron-e2e", + "surfaces": [ + "terminal lifecycle", + "window focus recovery", + "hidden-to-visible resume", + "xterm write backlog", + "scrollback" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon"], + "coverageNotes": "Unit coverage proves provider-independent ordering for any PaneManager. Live Electron coverage exercises a local PTY through the daemon on macOS; SSH, WSL, remote-runtime, Linux, and Windows remain unproved for this exact race.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11753", + "https://github.com/stablyai/orca/pull/11915" + ], + "invariant": "When output is queued during a focus or visibility transition, Orca records the pre-flush viewport intent before xterm parses backlog writes, so a follow-output terminal stays at the bottom and a pinned terminal keeps its prior position.", + "oracle": "Unit tests require exactly one intent sync before each queued-output flush. The Electron test injects a transient top-of-buffer xterm wobble during refocus and requires every presented scrollbar frame, including the final rendered output, to remain at the bottom.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-streaming-refocus-viewport.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=5" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "tests/e2e/terminal-streaming-refocus-viewport.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "assertions": [ + "window-wake recovery synchronizes viewport intent exactly once before flushing queued output", + "heavy visibility resume synchronizes intent exactly once before flushing queued output" + ] + }, + { + "file": "tests/e2e/terminal-streaming-refocus-viewport.spec.ts", + "assertions": [ + "phase-one scrollback is visibly ready at the bottom without a fixed sleep", + "no presented animation frame moves the scrollbar thumb away from the bottom during refocus", + "the final streamed marker renders with the visible scrollbar still at the bottom" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "result": "passed", + "durationSeconds": 0.096, + "summary": "14 tests passed, including exact sync count and pre-flush ordering for wake and heavy visibility resume." + }, + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-streaming-refocus-viewport.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=5", + "result": "passed", + "durationSeconds": 35.2, + "summary": "Five consecutive Electron iterations passed after replacing fixed-time readiness and stale tab capture with deterministic viewport and pane-identity oracles." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused renderer unit test or one Electron E2E iteration" + }, + "flakeHistory": { + "status": "soaking", + "evidence": "Five consecutive local Electron iterations passed; CI soak history is still required before promotion." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The controlled xterm viewport wobble reproduces the pinned-top failure with post-flush intent sampling and passes when intent is latched before the queued write flush." + }, + "performanceBudget": { + "required": true, + "evidence": "Wake recovery keeps one O(panes) intent pass before the existing bounded 64 KiB-per-pane flush. Heavy resume removes its second intent pass and keeps the existing bounded 256 KiB-per-pane flush; no polling, timers, subprocesses, IPC, output parsing, fit, or repaint work is added." + }, + "promotionCriteria": [ + "Collect stable CI soak history for the Electron race gate.", + "Run the live oracle on Linux and Windows terminal backends.", + "Add live SSH or remote-runtime coverage for queued output during refocus." + ], + "knownGaps": [ + "The deterministic wobble uses xterm private buffer state and must be updated if that internal contract changes.", + "Live Linux, Windows, SSH, WSL, and remote-runtime execution is not covered for this exact race.", + "The Electron gate proves follow-output behavior; adjacent scroll-intent coverage protects pinned viewport behavior." + ], + "demotionRule": "Demote or quarantine if the Electron oracle flakes without a product bug or harness bug filed to terminal-rendering." + }, { "id": "startup-upgrade.persisted-session-corpus", "title": "Current Orca preserves or recovers old production persisted sessions", diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 300a381c05a..17271179620 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -151,6 +151,50 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('latches viewport intent before refocus recovery flushes streaming output', async () => { + const terminal = { name: 'streaming-terminal' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal }]) + const { syncTerminalScrollIntentFromViewport } = vi.mocked( + await import('@/lib/pane-manager/terminal-scroll-intent') + ) + const { flushTerminalOutput } = vi.mocked( + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + ) + + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(flushTerminalOutput).toHaveBeenCalledOnce() + expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledOnce() + expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan( + flushTerminalOutput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('does not overwrite pre-reveal intent after queuing hidden output', async () => { + const terminal = { name: 'hidden-streaming-terminal' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal }]) + const { syncTerminalScrollIntentFromViewport } = vi.mocked( + await import('@/lib/pane-manager/terminal-scroll-intent') + ) + const { flushTerminalOutput } = vi.mocked( + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + ) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(flushTerminalOutput).toHaveBeenCalledOnce() + expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledOnce() + expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan( + flushTerminalOutput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + it('resets each pane linkifier hover cache on window wake recovery so links recover without a scroll', () => { const first = { name: 'pane-a' } const second = { name: 'pane-b' } diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 641c99258bc..5c6b8314545 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -143,6 +143,8 @@ export function recoverVisibleTerminalWindowWake({ }: RecoverVisibleTerminalWindowWakeArgs): void { // Why: macOS screensaver/display wake can leave xterm visible but with a // stale renderer/input surface; Orca's own hidden-state resume never runs. + // Why: backlog writes can expose transient viewport geometry while parsing. + syncTerminalViewportIntents(manager) for (const pane of manager.getPanes()) { requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) @@ -154,7 +156,6 @@ export function recoverVisibleTerminalWindowWake({ resetTerminalLinkifierHoverState(pane.terminal) } } - syncTerminalViewportIntents(manager) manager.resumeRendering() // Why: wake re-attaches WebGL — same transient cell-metric wobble guard as the heavy resume. manager.fitAllRevealedPanes() @@ -192,7 +193,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) } - syncTerminalViewportIntents(manager) + // Intent was latched by the caller before queued writes can expose transient geometry. // Resume WebGL immediately so the terminal shows its last-known state // on the first painted frame. macOS context creation is ~5 ms; on // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume diff --git a/tests/e2e/terminal-streaming-refocus-viewport.spec.ts b/tests/e2e/terminal-streaming-refocus-viewport.spec.ts new file mode 100644 index 00000000000..8376a3188a0 --- /dev/null +++ b/tests/e2e/terminal-streaming-refocus-viewport.spec.ts @@ -0,0 +1,240 @@ +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + getTerminalContent, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection' +import { nodeTerminalCommand } from './terminal-node-command' + +const STREAMING_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/streaming-scrollback-fixture.cjs' +) + +type RevealFrame = { + targetPresented: boolean + thumbTop: number | null + maxThumbTop: number | null +} + +async function closeFeatureTips(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) + if (store?.getState().activeModal === 'feature-tips') { + store.getState().closeModal() + } + }) +} + +async function waitForPhaseOneAtBottom(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((targetTabId) => { + const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0] + const terminal = pane?.terminal + if (!terminal) { + return false + } + const buffer = terminal.buffer.active + let containsMarker = false + for (let line = buffer.baseY; line < buffer.baseY + terminal.rows; line += 1) { + if (buffer.getLine(line)?.translateToString(true).includes('STREAM_PHASE1_DONE')) { + containsMarker = true + break + } + } + const scrollbar = pane.container.querySelector( + '.xterm-scrollbar.xterm-vertical' + ) + const thumb = scrollbar?.querySelector('.xterm-slider') ?? null + return Boolean( + buffer.baseY > 0 && + buffer.viewportY === buffer.baseY && + containsMarker && + scrollbar && + thumb && + scrollbar.clientHeight - thumb.offsetHeight > 1 && + Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2 + ) + }, tabId), + { + timeout: 30_000, + message: 'phase-one output did not render with the visible viewport at the bottom' + } + ) + .toBe(true) +} + +async function injectQueuedWriteAndRefocus( + page: Page, + tabId: string, + paneKey: string +): Promise { + await page.evaluate( + ({ targetTabId, paneKey }) => { + const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0] + if (!pane) { + throw new Error('Hidden terminal pane unavailable') + } + const terminal = pane.terminal + // Why: fail loudly if xterm moves the private buffer path that models this wobble. + const bufferService = ( + terminal as typeof terminal & { + _core?: { + _bufferService?: { buffer?: { ydisp: number }; isUserScrolling: boolean } + } + } + )._core?._bufferService + const internalBuffer = bufferService?.buffer + if (!internalBuffer || !bufferService) { + throw new Error('xterm internal buffer unavailable') + } + const originalWrite = terminal.write + let wobbleApplied = false + terminal.write = ((data: string, callback?: () => void) => { + terminal.write = originalWrite + wobbleApplied = true + internalBuffer.ydisp = 0 + bufferService.isUserScrolling = true + if (terminal.buffer.active.viewportY !== 0) { + throw new Error('xterm viewport wobble was not observable') + } + originalWrite.call(terminal, data, callback) + }) as typeof terminal.write + const injector = ( + window as Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string) => boolean + } + } + ).__terminalPtyDataInjection + const rows = Array.from( + { length: 400 }, + (_, index) => `REFOCUS_STREAM_ROW_${String(index).padStart(4, '0')}_${'x'.repeat(80)}\n` + ).join('') + if (!injector?.inject(paneKey, `${rows}REFOCUS_STREAM_DONE\n`)) { + throw new Error('PTY data injector unavailable') + } + // Why: focus recovery must flush through terminal.write in this synchronous dispatch. + window.dispatchEvent(new Event('focus')) + if (!wobbleApplied) { + throw new Error('refocus did not flush the queued xterm write') + } + }, + { targetTabId: tabId, paneKey } + ) +} + +async function sampleRevealFrames(page: Page, targetTabId: string): Promise { + return page.evaluate( + (targetTabId) => + new Promise((resolve) => { + const frames: RevealFrame[] = [] + const startedAt = performance.now() + const isPresented = (element: Element | null): boolean => { + if (!(element instanceof HTMLElement)) { + return false + } + for ( + let current: HTMLElement | null = element; + current; + current = current.parentElement + ) { + const style = getComputedStyle(current) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' + ) { + return false + } + } + const rect = element.getBoundingClientRect() + return ( + rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight + ) + } + const sample = (): void => { + const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0] + const targetXterm = pane?.container.querySelector('.xterm') ?? null + const scrollbar = + targetXterm?.querySelector('.xterm-scrollbar.xterm-vertical') ?? null + const thumb = scrollbar?.querySelector('.xterm-slider') ?? null + frames.push({ + targetPresented: isPresented(targetXterm), + thumbTop: thumb?.offsetTop ?? null, + maxThumbTop: scrollbar && thumb ? scrollbar.clientHeight - thumb.offsetHeight : null + }) + if (performance.now() - startedAt >= 700) { + resolve(frames) + return + } + requestAnimationFrame(sample) + } + sample() + }), + targetTabId + ) +} + +test.describe('terminal streaming refocus viewport', () => { + test('keeps follow-output at the bottom through a queued-write refocus wobble', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await closeFeatureTips(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) + const tabId = paneKey.slice(0, paneKey.indexOf(':')) + await waitForTerminalPtyDataInjector(orcaPage, paneKey) + await execInTerminal(orcaPage, ptyId, nodeTerminalCommand([STREAMING_FIXTURE_PATH])) + await waitForPhaseOneAtBottom(orcaPage, tabId) + + const framesPromise = sampleRevealFrames(orcaPage, tabId) + await injectQueuedWriteAndRefocus(orcaPage, tabId, paneKey) + const frames = await framesPromise + + expect(frames.filter((frame) => !frame.targetPresented)).toEqual([]) + expect( + frames.filter( + (frame) => + frame.thumbTop === null || + frame.maxThumbTop === null || + Math.abs(frame.maxThumbTop - frame.thumbTop) > 2 + ) + ).toEqual([]) + expect(frames.some((frame) => (frame.maxThumbTop ?? 0) > 1)).toBe(true) + expect( + frames.filter((frame) => (frame.maxThumbTop ?? 0) > 1 && (frame.thumbTop ?? 0) <= 1) + ).toEqual([]) + await expect + .poll(() => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('REFOCUS_STREAM_DONE') + const visibleScrollbar = orcaPage.locator('.xterm-scrollbar.xterm-vertical:visible').first() + await expect(visibleScrollbar).toBeVisible() + expect( + await visibleScrollbar.evaluate((scrollbar) => { + const thumb = scrollbar.querySelector('.xterm-slider') + return Boolean( + thumb && Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2 + ) + }) + ).toBe(true) + }) +}) From 33c14bc7164ccfa32fefb89126786a13a464ee5e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:27:42 -0700 Subject: [PATCH 6/9] fix(ssh): fall back to OpenSSH for FIDO2 keys (#11913) Closes #11645 --- src/main/ssh/ssh-auth-resolution.ts | 29 +- src/main/ssh/ssh-connection-utils.test.ts | 30 +- src/main/ssh/ssh-connection.test.ts | 63 +++- src/main/ssh/ssh-connection.ts | 35 +- .../ssh-security-key-identity.test-fixture.ts | 42 +++ .../ssh/ssh-security-key-identity.test.ts | 309 ++++++++++++++++++ src/main/ssh/ssh-security-key-identity.ts | 107 ++++++ src/main/ssh/ssh-system-fallback.test.ts | 18 +- src/main/ssh/ssh-transport-selection.ts | 118 +++++++ src/main/ssh/system-ssh-binary.test.ts | 106 ++++++ src/main/ssh/system-ssh-binary.ts | 46 ++- 11 files changed, 836 insertions(+), 67 deletions(-) create mode 100644 src/main/ssh/ssh-security-key-identity.test-fixture.ts create mode 100644 src/main/ssh/ssh-security-key-identity.test.ts create mode 100644 src/main/ssh/ssh-security-key-identity.ts create mode 100644 src/main/ssh/ssh-transport-selection.ts create mode 100644 src/main/ssh/system-ssh-binary.test.ts diff --git a/src/main/ssh/ssh-auth-resolution.ts b/src/main/ssh/ssh-auth-resolution.ts index 025c8904870..c7e65e26d92 100644 --- a/src/main/ssh/ssh-auth-resolution.ts +++ b/src/main/ssh/ssh-auth-resolution.ts @@ -8,18 +8,26 @@ import { isOpenSshConfigBackedTarget } from './system-ssh-args' // Why: ssh2 only tries keys that are explicitly provided. Users with keys in // standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would -// fail to authenticate. Probing default paths matches VS Code's _findDefaultKeyFile. +// fail to authenticate. Probe the regular and FIDO2 OpenSSH default paths. const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa', 'id_ecdsa', 'id_dsa', 'id_xmss'] +const DEFAULT_SECURITY_KEY_NAMES = ['id_ed25519_sk', 'id_ecdsa_sk'] const DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => `~/.ssh/${name}`) +const DEFAULT_IDENTITY_PATHS = [...DEFAULT_KEY_NAMES, ...DEFAULT_SECURITY_KEY_NAMES].map( + (name) => `~/.ssh/${name}` +) const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' // Why: resolved IdentityFile paths are expanded before auth resolution, so they // won't match the ~/... form in DEFAULT_KEY_PATHS. -const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_PATHS.map(resolveSshConfigHomePath) +const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_IDENTITY_PATHS.map(resolveSshConfigHomePath) export type PrivateKeyFile = { path: string; contents: Buffer } +export function listDefaultIdentityFilePaths(): string[] { + return [...DEFAULT_IDENTITY_PATHS] +} + export function findDefaultKeyFile(): PrivateKeyFile | undefined { for (const keyPath of DEFAULT_KEY_PATHS) { const resolved = resolveSshConfigHomePath(keyPath) @@ -97,7 +105,10 @@ function resolveExplicitPrivateKeyPaths( return resolvedIdentities } -function resolvePrivateKeyPaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] { +export function resolveIdentityFilePaths( + target: SshTarget, + resolved: Pick | null +): string[] { if (isOpenSshConfigBackedTarget(target) && resolved) { return resolved.identityFile } @@ -138,7 +149,7 @@ export function resolvePrivateKeys( target: SshTarget, resolved: SshResolvedConfig | null ): PrivateKeyFile[] { - const keyPaths = resolvePrivateKeyPaths(target, resolved) + const keyPaths = resolveIdentityFilePaths(target, resolved) if (keyPaths.length > 0 || resolved || target.identityFile) { return readPrivateKeys(keyPaths) } @@ -174,16 +185,6 @@ export function findEncryptedPrivateKeyPath(keys: PrivateKeyFile[]): string | un return undefined } -function resolveIdentityFilePaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] { - if (isOpenSshConfigBackedTarget(target) && resolved) { - return resolved.identityFile - } - if (target.identityFile) { - return [target.identityFile] - } - return resolved?.identityFile ?? [] -} - export function resolveAgentConfigValue( agentSocket: string, target: SshTarget, diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index a5dd743fd43..5e06a9e7386 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -313,7 +313,7 @@ describe('findDefaultKeyFile', () => { expect(result!.contents).toEqual(Buffer.from('key-contents')) }) - it('probes keys in VS Code order: ed25519, rsa, ecdsa, dsa, xmss', () => { + it('probes regular and FIDO2 keys in stable default order', () => { const checkedPaths: string[] = [] mockExistsSync.mockImplementation((path: unknown) => { checkedPaths.push(String(path)) @@ -331,6 +331,34 @@ describe('findDefaultKeyFile', () => { ]) }) + it('keeps a regular default ahead of a malformed FIDO2 default', () => { + mockExistsSync.mockImplementation((path: unknown) => { + return ( + path === testHomePath('.ssh', 'id_rsa') || path === testHomePath('.ssh', 'id_ed25519_sk') + ) + }) + mockReadFileSync.mockImplementation((path: unknown) => { + if (String(path) === testHomePath('.ssh', 'id_ed25519_sk')) { + throw new Error('malformed FIDO2 key') + } + return Buffer.from('rsa-key') + }) + + expect(findDefaultKeyFile()).toEqual({ + path: '~/.ssh/id_rsa', + contents: Buffer.from('rsa-key') + }) + }) + + it('leaves FIDO2 defaults out of the ssh2 private-key fallback', () => { + mockExistsSync.mockImplementation((path: unknown) => { + return path === testHomePath('.ssh', 'id_ed25519_sk') + }) + + expect(findDefaultKeyFile()).toBeUndefined() + expect(mockReadFileSync).not.toHaveBeenCalled() + }) + it('skips unreadable key files and tries next', () => { mockExistsSync.mockImplementation((path: unknown) => { return path === testHomePath('.ssh', 'id_ed25519') || path === testHomePath('.ssh', 'id_rsa') diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index b73cc1e1db3..a3bcadf66dc 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -16,6 +16,7 @@ let execBehavior: 'callback' | 'pending' = 'callback' let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null let sftpBehavior: 'callback' | 'pending' = 'callback' let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null +let notifyClientCreated: (() => void) | undefined type MockSshClient = { setNoDelay: ReturnType @@ -45,6 +46,8 @@ vi.mock('ssh2', () => { lastConnectConfig?: unknown constructor() { clientInstances.push(this) + notifyClientCreated?.() + notifyClientCreated = undefined } on(event: string, handler: (...args: unknown[]) => void) { const handlers = eventHandlers?.get(event) ?? new Set<(...args: unknown[]) => void>() @@ -168,6 +171,10 @@ import { } from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' +import { + createOpenSshPrivateKeyFixture, + createOpenSshPublicKeyFixture +} from './ssh-security-key-identity.test-fixture' function createTarget(overrides?: Partial): SshTarget { return { @@ -278,6 +285,7 @@ describe('SshConnection', () => { pendingExecCallback = null sftpBehavior = 'callback' pendingSftpCallback = null + notifyClientCreated = undefined clientInstances = [] getOrcaControlSocketPathMock.mockReset() getOrcaControlSocketPathMock.mockReturnValue(null) @@ -462,10 +470,11 @@ describe('SshConnection', () => { const callbacks = createCallbacks() const conn = new SshConnection(createTarget(), callbacks) + const clientCreated = new Promise((resolve) => { + notifyClientCreated = resolve + }) const connectResult = conn.connect().catch((error: Error) => error) - for (let i = 0; i < 5 && clientInstances.length === 0; i++) { - await Promise.resolve() - } + await clientCreated expect(clientInstances).toHaveLength(1) await conn.disconnect() @@ -485,10 +494,11 @@ describe('SshConnection', () => { const callbacks = createCallbacks() const conn = new SshConnection(createTarget(), callbacks) + const clientCreated = new Promise((resolve) => { + notifyClientCreated = resolve + }) const connectResult = conn.connect().catch((error: Error) => error) - for (let i = 0; i < 5 && clientInstances.length === 0; i++) { - await Promise.resolve() - } + await clientCreated expect(clientInstances).toHaveLength(1) await conn.disconnect() @@ -1462,6 +1472,47 @@ describe('SshConnection', () => { ) }) + it('uses system SSH before ssh2 parses a security-key private key', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-connect-')) + const keyPath = join(directory, 'id_ed25519_sk') + writeFileSync( + keyPath, + createOpenSshPrivateKeyFixture(['sk-ssh-ed25519@openssh.com'], { encrypted: true }) + ) + const conn = new SshConnection(createTarget({ identityFile: keyPath }), createCallbacks()) + + try { + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + expect(clientInstances).toHaveLength(0) + expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(directory, { recursive: true }) + } + }) + + it('uses system SSH for an agent-backed security-key public identity', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-agent-connect-')) + const identityPath = join(directory, 'id_ed25519_sk') + writeFileSync( + `${identityPath}.pub`, + createOpenSshPublicKeyFixture('sk-ssh-ed25519@openssh.com') + ) + const conn = new SshConnection(createTarget({ identityFile: identityPath }), createCallbacks()) + + try { + await conn.connect() + + expect(conn.usesSystemSshTransport()).toBe(true) + expect(clientInstances).toHaveLength(0) + expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1) + } finally { + rmSync(directory, { recursive: true }) + } + }) + it('falls back to system SSH when ssh2 hits a local network policy reachability error', async () => { connectBehavior = 'error' connectErrorMessage = 'connect EHOSTUNREACH 192.168.0.210:22 - Local (192.168.0.2:52112)' diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 17f0a382251..37d661587ad 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -41,6 +41,10 @@ import { type SshConnectionCallbacks } from './ssh-connection-utils' import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' +import { + requiresSystemSshForSecurityKey, + shouldUseSystemSshTransport +} from './ssh-transport-selection' import type { RemoteHostPlatform } from './ssh-remote-platform' import { resolveSftpTransferPathIfMapped, @@ -636,7 +640,14 @@ export class SshConnection { const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch( () => null ) - if (shouldUseSystemSshTransport(this.target, resolved)) { + const usesConfiguredSystemTransport = shouldUseSystemSshTransport(this.target, resolved) + const requiresSecurityKeyTransport = usesConfiguredSystemTransport + ? false + : await requiresSystemSshForSecurityKey(this.target, resolved) + if (!this.isCurrentConnectAttempt(connectGeneration)) { + throw this.createCancelledConnectAttemptError() + } + if (usesConfiguredSystemTransport || requiresSecurityKeyTransport) { await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved) return } @@ -1386,24 +1397,4 @@ export class SshConnection { } } -export function shouldUseSystemSshTransport( - target: SshTarget, - resolved: Pick | null -): boolean { - if (isOpenSshConfigBackedTarget(target) && resolved) { - return ( - process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || - resolved.proxyUseFdpass === true || - resolved.proxyCommand != null || - resolved.proxyJump != null - ) - } - return ( - process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || - target.proxyCommand != null || - target.jumpHost != null || - resolved?.proxyUseFdpass === true || - resolved?.proxyCommand != null || - resolved?.proxyJump != null - ) -} +export { shouldUseSystemSshTransport } from './ssh-transport-selection' diff --git a/src/main/ssh/ssh-security-key-identity.test-fixture.ts b/src/main/ssh/ssh-security-key-identity.test-fixture.ts new file mode 100644 index 00000000000..0a8bcbd58f4 --- /dev/null +++ b/src/main/ssh/ssh-security-key-identity.test-fixture.ts @@ -0,0 +1,42 @@ +function encodeUint32(value: number): Buffer { + const buffer = Buffer.alloc(4) + buffer.writeUInt32BE(value) + return buffer +} + +function sshString(value: string | Buffer): Buffer { + const contents = typeof value === 'string' ? Buffer.from(value, 'ascii') : value + return Buffer.concat([encodeUint32(contents.length), contents]) +} + +export function createOpenSshPrivateKeyFixture( + keyTypes: string[], + options: { encrypted?: boolean; cipher?: string; privateBlock?: Buffer; authTag?: Buffer } = {} +): Buffer { + const cipher = options.cipher ?? (options.encrypted ? 'aes256-ctr' : 'none') + const encrypted = cipher !== 'none' + const publicKeys = keyTypes.map((keyType) => sshString(sshString(keyType))) + const decoded = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'ascii'), + sshString(cipher), + sshString(encrypted ? 'bcrypt' : 'none'), + sshString(encrypted ? Buffer.from('fixture-kdf') : Buffer.alloc(0)), + encodeUint32(publicKeys.length), + ...publicKeys, + sshString(options.privateBlock ?? Buffer.from('fixture-private-block')), + options.authTag ?? Buffer.alloc(0) + ]) + const encoded = + decoded + .toString('base64') + .match(/.{1,70}/g) + ?.join('\n') ?? '' + return Buffer.from( + `-----BEGIN OPENSSH PRIVATE KEY-----\n${encoded}\n-----END OPENSSH PRIVATE KEY-----\n` + ) +} + +export function createOpenSshPublicKeyFixture(keyType: string): Buffer { + const encoded = sshString(keyType).toString('base64') + return Buffer.from(`${keyType} ${encoded} fixture-comment\n`) +} diff --git a/src/main/ssh/ssh-security-key-identity.test.ts b/src/main/ssh/ssh-security-key-identity.test.ts new file mode 100644 index 00000000000..c424bfa6891 --- /dev/null +++ b/src/main/ssh/ssh-security-key-identity.test.ts @@ -0,0 +1,309 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' +import { + isOpenSshSecurityKeyPrivateKey, + isOpenSshSecurityKeyPublicKey +} from './ssh-security-key-identity' +import { + createOpenSshPrivateKeyFixture, + createOpenSshPublicKeyFixture +} from './ssh-security-key-identity.test-fixture' +import { requiresSystemSshForSecurityKey } from './ssh-transport-selection' + +const { findSystemSshMock } = vi.hoisted(() => ({ findSystemSshMock: vi.fn() })) + +vi.mock('./system-ssh-binary', () => ({ findSystemSsh: findSystemSshMock })) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal<{ homedir: () => string }>() + return { + ...actual, + homedir: () => process.env.ORCA_TEST_SSH_HOME || actual.homedir() + } +}) + +const ED25519_SECURITY_KEY = 'sk-ssh-ed25519@openssh.com' +const ECDSA_SECURITY_KEY = 'sk-ecdsa-sha2-nistp256@openssh.com' +const tempDirs: string[] = [] + +function createTarget(overrides: Partial = {}): SshTarget { + return { + id: 'target-1', + label: 'Test Server', + host: 'example.com', + port: 22, + username: 'deploy', + ...overrides + } +} + +beforeEach(() => { + findSystemSshMock.mockReset() + findSystemSshMock.mockReturnValue('/usr/bin/ssh') +}) + +async function writeKey(contents: Buffer, filename = 'security key'): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-')) + tempDirs.push(directory) + const keyPath = join(directory, filename) + await writeFile(keyPath, contents) + return keyPath +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true }))) +}) + +describe('isOpenSshSecurityKeyPrivateKey', () => { + it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])( + 'recognizes unencrypted %s keys', + (keyType) => { + expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(true) + } + ) + + it('recognizes authenticated encrypted envelopes with a trailing tag', () => { + const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], { + cipher: 'aes256-gcm@openssh.com', + authTag: Buffer.alloc(16, 7) + }) + expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true) + }) + + it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])( + 'recognizes encrypted %s keys from the public section', + (keyType) => { + const key = createOpenSshPrivateKeyFixture([keyType], { encrypted: true }) + expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true) + } + ) + + it.each(['ssh-ed25519', 'ecdsa-sha2-nistp256', 'ssh-rsa'])( + 'leaves regular %s keys on ssh2', + (keyType) => { + expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(false) + } + ) + + it('supports CRLF armored keys', () => { + const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + expect( + isOpenSshSecurityKeyPrivateKey(Buffer.from(key.toString().replaceAll('\n', '\r\n'))) + ).toBe(true) + }) + + it('recognizes OpenSSH envelopes without optional base64 padding', () => { + const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], { + privateBlock: Buffer.alloc(0) + }) + const unpadded = Buffer.from(key.toString().replace(/=+(?=\n-----END)/, '')) + expect(unpadded).not.toEqual(key) + expect(isOpenSshSecurityKeyPrivateKey(unpadded)).toBe(true) + }) + + it('does not match security-key text outside a valid public-key type', () => { + const key = createOpenSshPrivateKeyFixture(['ssh-ed25519'], { + privateBlock: Buffer.from(ED25519_SECURITY_KEY) + }) + expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(false) + expect( + isOpenSshSecurityKeyPrivateKey(Buffer.from(`${ED25519_SECURITY_KEY} AAAA comment`)) + ).toBe(false) + }) + + it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])( + 'validates the %s type inside an OpenSSH public key blob', + (keyType) => { + expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture(keyType))).toBe(true) + } + ) + + it('recognizes public keys without optional base64 padding', () => { + const key = createOpenSshPublicKeyFixture(ECDSA_SECURITY_KEY) + const unpadded = Buffer.from(key.toString().replace(/=+(?=\s)/, '')) + expect(unpadded).not.toEqual(key) + expect(isOpenSshSecurityKeyPublicKey(unpadded)).toBe(true) + }) + + it('rejects regular or mismatched OpenSSH public key blobs', () => { + expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture('ssh-ed25519'))).toBe(false) + expect( + isOpenSshSecurityKeyPublicKey( + Buffer.from(`${ED25519_SECURITY_KEY} ${Buffer.from('ssh-ed25519').toString('base64')}`) + ) + ).toBe(false) + }) + + it('rejects malformed and truncated OpenSSH envelopes without throwing', () => { + const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + const malformedLength = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'ascii'), + Buffer.from([0xff, 0xff, 0xff, 0xff]) + ]).toString('base64') + const malformedKey = Buffer.from( + `-----BEGIN OPENSSH PRIVATE KEY-----\n${malformedLength}\n-----END OPENSSH PRIVATE KEY-----\n` + ) + expect(isOpenSshSecurityKeyPrivateKey(key.subarray(0, -20))).toBe(false) + expect(isOpenSshSecurityKeyPrivateKey(malformedKey)).toBe(false) + expect(isOpenSshSecurityKeyPrivateKey(Buffer.from('not a private key'))).toBe(false) + }) +}) + +describe('requiresSystemSshForSecurityKey', () => { + it('uses default FIDO2 identities only when config resolution is unavailable', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + await writeFile( + join(directory, '.ssh', 'id_ed25519_sk'), + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + ) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true) + await expect( + requiresSystemSshForSecurityKey(createTarget(), { identityFile: [] }) + ).resolves.toBe(false) + }) + + it('keeps a regular default ahead of a dormant FIDO2 identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-regular-key-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + await writeFile(join(directory, '.ssh', 'id_rsa'), createOpenSshPrivateKeyFixture(['ssh-rsa'])) + await writeFile( + join(directory, '.ssh', 'id_ed25519_sk'), + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + ) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false) + }) + + it('keeps password and agent fallback when default FIDO2 needs unavailable OpenSSH', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-no-system-ssh-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + await writeFile( + join(directory, '.ssh', 'id_ed25519_sk'), + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + ) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + findSystemSshMock.mockReturnValue(null) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false) + }) + + it('ignores an orphan regular sidecar before a valid default FIDO2 identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-orphan-sidecar-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + await writeFile( + join(directory, '.ssh', 'id_ed25519.pub'), + createOpenSshPublicKeyFixture('ssh-ed25519') + ) + await writeFile( + join(directory, '.ssh', 'id_ed25519_sk'), + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + ) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true) + }) + + it('detects a manual target identity path with spaces', async () => { + const keyPath = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])) + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: keyPath }), null) + ).resolves.toBe(true) + }) + + it('checks every fresh resolved identity for config-backed targets', async () => { + const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular') + const securityKey = await writeKey( + createOpenSshPrivateKeyFixture([ECDSA_SECURITY_KEY], { encrypted: true }), + 'security' + ) + const target = createTarget({ + source: 'ssh-config', + configHost: 'workbox', + identityFile: '/stale/security-key' + }) + + await expect( + requiresSystemSshForSecurityKey(target, { identityFile: [regularKey, securityKey] }) + ).resolves.toBe(true) + }) + + it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])( + 'detects an agent-backed %s identity from its public sidecar', + async (keyType) => { + const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-agent-')) + tempDirs.push(directory) + const identityPath = join(directory, 'agent-key') + await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(keyType)) + + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null) + ).resolves.toBe(true) + } + ) + + it('ignores a stale FIDO2 sidecar beside a regular private identity', async () => { + const identityPath = await writeKey( + createOpenSshPrivateKeyFixture(['ssh-ed25519']), + 'regular-with-stale-sidecar' + ) + await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(ED25519_SECURITY_KEY)) + + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null) + ).resolves.toBe(false) + }) + + it('ignores stale imported identity paths when fresh config has regular keys', async () => { + const staleKey = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]), 'stale') + const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular') + const target = createTarget({ + source: 'ssh-config', + configHost: 'workbox', + identityFile: staleKey + }) + + await expect( + requiresSystemSshForSecurityKey(target, { identityFile: [regularKey] }) + ).resolves.toBe(false) + }) + + it('keeps a manual target identity authoritative over resolved defaults', async () => { + const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'manual') + const securityKey = await writeKey( + createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]), + 'resolved' + ) + + await expect( + requiresSystemSshForSecurityKey( + createTarget({ source: 'manual', identityFile: regularKey }), + { identityFile: [securityKey] } + ) + ).resolves.toBe(false) + }) + + it('degrades to ssh2 when identity files are missing or malformed', async () => { + const malformedKey = await writeKey(Buffer.from('not a key'), 'malformed') + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: malformedKey }), null) + ).resolves.toBe(false) + await expect( + requiresSystemSshForSecurityKey( + createTarget({ identityFile: join(tmpdir(), 'missing-security-key') }), + null + ) + ).resolves.toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-security-key-identity.ts b/src/main/ssh/ssh-security-key-identity.ts new file mode 100644 index 00000000000..bb1af581d7d --- /dev/null +++ b/src/main/ssh/ssh-security-key-identity.ts @@ -0,0 +1,107 @@ +const OPENSSH_PRIVATE_KEY_HEADER = '-----BEGIN OPENSSH PRIVATE KEY-----' +const OPENSSH_PRIVATE_KEY_FOOTER = '-----END OPENSSH PRIVATE KEY-----' +const OPENSSH_KEY_MAGIC = Buffer.from('openssh-key-v1\0', 'ascii') +export const MAX_SSH_IDENTITY_FILE_BYTES = 1024 * 1024 +const MAX_PUBLIC_KEYS = 64 +const SECURITY_KEY_TYPES = new Set([ + 'sk-ssh-ed25519@openssh.com', + 'sk-ecdsa-sha2-nistp256@openssh.com' +]) + +type SshString = { value: Buffer; nextOffset: number } + +function decodeBase64(value: string): Buffer | null { + if (!value || value.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + return null + } + const unpadded = value.replace(/=+$/, '') + const decoded = Buffer.from(unpadded, 'base64') + return decoded.toString('base64').replace(/=+$/, '') === unpadded ? decoded : null +} + +function readSshString(buffer: Buffer, offset: number): SshString | null { + if (offset < 0 || offset + 4 > buffer.length) { + return null + } + const length = buffer.readUInt32BE(offset) + const start = offset + 4 + const end = start + length + if (end < start || end > buffer.length) { + return null + } + return { value: buffer.subarray(start, end), nextOffset: end } +} + +function decodeOpenSshPrivateKey(contents: Buffer): Buffer | null { + if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) { + return null + } + const lines = contents.toString('ascii').trim().split(/\r?\n/) + if (lines[0] !== OPENSSH_PRIVATE_KEY_HEADER || lines.at(-1) !== OPENSSH_PRIVATE_KEY_FOOTER) { + return null + } + const encoded = lines.slice(1, -1).join('') + const decoded = decodeBase64(encoded) + if (!decoded) { + return null + } + return decoded.subarray(0, OPENSSH_KEY_MAGIC.length).equals(OPENSSH_KEY_MAGIC) ? decoded : null +} + +export function isOpenSshSecurityKeyPrivateKey(contents: Buffer): boolean { + const decoded = decodeOpenSshPrivateKey(contents) + if (!decoded) { + return false + } + + let offset = OPENSSH_KEY_MAGIC.length + for (let field = 0; field < 3; field++) { + const value = readSshString(decoded, offset) + if (!value) { + return false + } + offset = value.nextOffset + } + if (offset + 4 > decoded.length) { + return false + } + + const keyCount = decoded.readUInt32BE(offset) + offset += 4 + if (keyCount === 0 || keyCount > MAX_PUBLIC_KEYS) { + return false + } + + let hasSecurityKey = false + for (let keyIndex = 0; keyIndex < keyCount; keyIndex++) { + const publicKey = readSshString(decoded, offset) + if (!publicKey) { + return false + } + offset = publicKey.nextOffset + const keyType = readSshString(publicKey.value, 0) + if (!keyType) { + return false + } + hasSecurityKey ||= SECURITY_KEY_TYPES.has(keyType.value.toString('ascii')) + } + + const privateBlock = readSshString(decoded, offset) + return privateBlock !== null && hasSecurityKey +} + +export function isOpenSshSecurityKeyPublicKey(contents: Buffer): boolean { + if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) { + return false + } + const [declaredType, encoded] = contents.toString('ascii').trim().split(/\s+/, 3) + if (!declaredType || !encoded || !SECURITY_KEY_TYPES.has(declaredType)) { + return false + } + const decoded = decodeBase64(encoded) + if (!decoded) { + return false + } + const keyType = readSshString(decoded, 0) + return keyType?.value.toString('ascii') === declaredType +} diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 36312762d08..03e3e8baf53 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -24,7 +24,6 @@ vi.mock('child_process', () => ({ import { buildSshArgs, - findSystemSsh, downloadFileViaSystemSsh, spawnSystemSsh, spawnSystemSshCommand, @@ -152,22 +151,6 @@ function createMockChildProcess(): EventEmitter & { return child } -describe('findSystemSsh', () => { - beforeEach(() => { - existsSyncMock.mockReset() - }) - - it('returns the first existing ssh path', () => { - mockSystemSshExists() - expect(findSystemSsh()).toBe(SYSTEM_SSH_PATH) - }) - - it('returns null when no ssh binary is found', () => { - existsSyncMock.mockReturnValue(false) - expect(findSystemSsh()).toBeNull() - }) -}) - describe('spawnSystemSsh', () => { let mockProc: { stdin: { @@ -827,6 +810,7 @@ describe('spawnSystemSsh', () => { it('throws when no system ssh is found', () => { existsSyncMock.mockReturnValue(false) + vi.stubEnv('PATH', '') expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found') }) diff --git a/src/main/ssh/ssh-transport-selection.ts b/src/main/ssh/ssh-transport-selection.ts new file mode 100644 index 00000000000..fdbfb87075f --- /dev/null +++ b/src/main/ssh/ssh-transport-selection.ts @@ -0,0 +1,118 @@ +import { constants } from 'node:fs' +import { open, stat } from 'node:fs/promises' +import type { SshTarget } from '../../shared/ssh-types' +import type { SshResolvedConfig } from './ssh-config-parser' +import { listDefaultIdentityFilePaths, resolveIdentityFilePaths } from './ssh-auth-resolution' +import { resolveSshConfigHomePath } from './ssh-config-path-expansion' +import { + MAX_SSH_IDENTITY_FILE_BYTES, + isOpenSshSecurityKeyPrivateKey, + isOpenSshSecurityKeyPublicKey +} from './ssh-security-key-identity' +import { isOpenSshConfigBackedTarget } from './system-ssh-args' +import { findSystemSsh } from './system-ssh-binary' + +type TransportResolvedConfig = Pick< + SshResolvedConfig, + 'proxyUseFdpass' | 'proxyCommand' | 'proxyJump' +> + +const READ_CHUNK_BYTES = 64 * 1024 +const READ_OPEN_FLAGS = + constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK) + +type IdentityInspection = { privateIdentityExists: boolean; requiresSystemSsh: boolean } + +async function readBoundedKeyFile(path: string): Promise { + let handle: Awaited> | undefined + try { + const pathStats = await stat(path) + if (!pathStats.isFile() || pathStats.size > MAX_SSH_IDENTITY_FILE_BYTES) { + return null + } + handle = await open(path, READ_OPEN_FLAGS) + const stats = await handle.stat() + if (!stats.isFile() || stats.size > MAX_SSH_IDENTITY_FILE_BYTES) { + return null + } + const chunks: Buffer[] = [] + let offset = 0 + while (offset <= MAX_SSH_IDENTITY_FILE_BYTES) { + const buffer = Buffer.alloc( + Math.min(READ_CHUNK_BYTES, MAX_SSH_IDENTITY_FILE_BYTES + 1 - offset) + ) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset) + if (bytesRead === 0) { + break + } + offset += bytesRead + if (offset > MAX_SSH_IDENTITY_FILE_BYTES) { + return null + } + chunks.push(buffer.subarray(0, bytesRead)) + } + return Buffer.concat(chunks, offset) + } catch { + return null + } finally { + await handle?.close().catch(() => undefined) + } +} + +async function inspectIdentityPath(keyPath: string): Promise { + const resolvedPath = resolveSshConfigHomePath(keyPath) + const identity = await readBoundedKeyFile(resolvedPath) + if (identity !== null) { + return { + privateIdentityExists: true, + requiresSystemSsh: + isOpenSshSecurityKeyPublicKey(identity) || isOpenSshSecurityKeyPrivateKey(identity) + } + } + const publicIdentity = await readBoundedKeyFile(`${resolvedPath}.pub`) + return { + privateIdentityExists: false, + requiresSystemSsh: publicIdentity !== null && isOpenSshSecurityKeyPublicKey(publicIdentity) + } +} + +export function shouldUseSystemSshTransport( + target: SshTarget, + resolved: TransportResolvedConfig | null +): boolean { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return ( + process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || + resolved.proxyUseFdpass === true || + resolved.proxyCommand != null || + resolved.proxyJump != null + ) + } + return ( + process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || + target.proxyCommand != null || + target.jumpHost != null || + resolved?.proxyUseFdpass === true || + resolved?.proxyCommand != null || + resolved?.proxyJump != null + ) +} + +export async function requiresSystemSshForSecurityKey( + target: SshTarget, + resolved: Pick | null +): Promise { + const configuredPaths = resolveIdentityFilePaths(target, resolved) + const usesDefaultPaths = configuredPaths.length === 0 && !resolved && !target.identityFile + const identityPaths = usesDefaultPaths ? listDefaultIdentityFilePaths() : configuredPaths + for (const keyPath of identityPaths) { + const inspection = await inspectIdentityPath(keyPath) + if (inspection.requiresSystemSsh) { + return !usesDefaultPaths || findSystemSsh() !== null + } + if (usesDefaultPaths && inspection.privateIdentityExists) { + return false + } + } + return false +} diff --git a/src/main/ssh/system-ssh-binary.test.ts b/src/main/ssh/system-ssh-binary.test.ts new file mode 100644 index 00000000000..a3be418d2fc --- /dev/null +++ b/src/main/ssh/system-ssh-binary.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { accessSyncMock, existsSyncMock, statSyncMock } = vi.hoisted(() => ({ + accessSyncMock: vi.fn(), + existsSyncMock: vi.fn(), + statSyncMock: vi.fn() +})) + +vi.mock('node:fs', () => ({ + accessSync: accessSyncMock, + constants: { O_RDONLY: 0, O_NONBLOCK: 4, X_OK: 1 }, + existsSync: existsSyncMock, + statSync: statSyncMock +})) + +import { findSystemSsh } from './system-ssh-binary' + +describe('findSystemSsh', () => { + beforeEach(() => { + accessSyncMock.mockReset() + existsSyncMock.mockReset() + statSyncMock.mockReset() + statSyncMock.mockImplementation(() => { + throw new Error('missing') + }) + vi.unstubAllEnvs() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('returns the first existing fixed ssh path', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + existsSyncMock.mockImplementation((path: string) => path === '/usr/bin/ssh') + + try { + expect(findSystemSsh()).toBe('/usr/bin/ssh') + } finally { + platformSpy.mockRestore() + } + }) + + it('returns null when no ssh binary is found', () => { + existsSyncMock.mockReturnValue(false) + expect(findSystemSsh()).toBeNull() + }) + + it('finds a PATH-installed ssh.exe on Windows', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('PATH', 'C:\\Git\\usr\\bin;C:\\Tools') + existsSyncMock.mockReturnValue(false) + statSyncMock.mockImplementation((path: string) => { + if (path === 'C:\\Git\\usr\\bin\\ssh.exe') { + return { isFile: () => true } + } + throw new Error('missing') + }) + + try { + expect(findSystemSsh()).toBe('C:\\Git\\usr\\bin\\ssh.exe') + } finally { + platformSpy.mockRestore() + } + }) + + it('finds an executable PATH-installed ssh on POSIX', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + vi.stubEnv('PATH', '/nix/store/ssh/bin:/opt/tools') + existsSyncMock.mockReturnValue(false) + statSyncMock.mockImplementation((path: string) => { + if (path === '/nix/store/ssh/bin/ssh') { + return { isFile: () => true } + } + throw new Error('missing') + }) + + try { + expect(findSystemSsh()).toBe('/nix/store/ssh/bin/ssh') + expect(accessSyncMock).toHaveBeenCalledWith('/nix/store/ssh/bin/ssh', expect.any(Number)) + } finally { + platformSpy.mockRestore() + } + }) + + it('resolves Windows OpenSSH from the runtime system root', () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('SystemRoot', 'D:\\Windows') + existsSyncMock.mockImplementation( + (path: string) => path === 'D:\\Windows\\System32\\OpenSSH\\ssh.exe' + ) + + try { + expect(findSystemSsh()).toBe('D:\\Windows\\System32\\OpenSSH\\ssh.exe') + } finally { + platformSpy.mockRestore() + } + }) + + it('keeps an explicit system ssh override authoritative', () => { + vi.stubEnv('ORCA_SYSTEM_SSH_PATH', 'C:\\Custom\\ssh.exe') + + expect(findSystemSsh()).toBe('C:\\Custom\\ssh.exe') + expect(existsSyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/system-ssh-binary.ts b/src/main/ssh/system-ssh-binary.ts index 579b483392c..bc8ff3ea516 100644 --- a/src/main/ssh/system-ssh-binary.ts +++ b/src/main/ssh/system-ssh-binary.ts @@ -1,9 +1,41 @@ -import { existsSync } from 'node:fs' +import { accessSync, constants, existsSync, statSync } from 'node:fs' +import { posix, win32 } from 'node:path' -const SYSTEM_SSH_PATHS = - process.platform === 'win32' - ? ['C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'ssh.exe'] - : ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh'] +function systemSshPaths(platform: NodeJS.Platform): string[] { + if (platform !== 'win32') { + return ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh'] + } + const systemRoot = process.env.SystemRoot || process.env.WINDIR + return systemRoot ? [win32.join(systemRoot, 'System32', 'OpenSSH', 'ssh.exe')] : [] +} + +function findSshOnPath(platform: NodeJS.Platform): string | null { + const pathValue = process.env.PATH + if (!pathValue) { + return null + } + const pathApi = platform === 'win32' ? win32 : posix + const executable = platform === 'win32' ? 'ssh.exe' : 'ssh' + for (const entry of pathValue.split(pathApi.delimiter)) { + const directory = entry.trim().replace(/^"|"$/g, '') + if (!directory) { + continue + } + const candidate = pathApi.join(directory, executable) + try { + if (!statSync(candidate).isFile()) { + continue + } + if (platform !== 'win32') { + accessSync(candidate, constants.X_OK) + } + return candidate + } catch { + continue + } + } + return null +} /** * Find the system ssh binary path. Returns null if not found. @@ -12,10 +44,10 @@ export function findSystemSsh(): string | null { if (process.env.ORCA_SYSTEM_SSH_PATH) { return process.env.ORCA_SYSTEM_SSH_PATH } - for (const candidate of SYSTEM_SSH_PATHS) { + for (const candidate of systemSshPaths(process.platform)) { if (existsSync(candidate)) { return candidate } } - return null + return findSshOnPath(process.platform) } From 16c5526dfda787ee3bac9925532abc2c39b78fc5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:35:34 -0700 Subject: [PATCH 7/9] fix(daemon): cover in-flight sleep in PAM watch (#11921) * fix(daemon): rebaseline in-flight PAM suspension * test(activity): await portal readiness commits --- config/reliability-gates.jsonc | 14 ++-- src/main/daemon/login-session-watch-clock.ts | 17 ++++ src/main/daemon/login-session-watch-timing.ts | 19 +++++ .../macos-login-session-death-watch.test.ts | 66 +++++++++++++-- .../daemon/macos-login-session-death-watch.ts | 81 +++++++++---------- ...ty-portal-readiness-loop.react185.test.tsx | 13 ++- 6 files changed, 154 insertions(+), 56 deletions(-) create mode 100644 src/main/daemon/login-session-watch-clock.ts create mode 100644 src/main/daemon/login-session-watch-timing.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 01db646c217..80cb0d5865f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -26,13 +26,13 @@ "providers": ["local-daemon"], "coveredPlatforms": ["macos"], "coveredProviders": ["local-daemon"], - "coverageNotes": "A deterministic fake clock covers short-burst recovery, sustained session death, event-trigger preemption, timer suspension, resolver corroboration, and shutdown cancellation. The production-only watch remains behind the macOS GUI launch flag; SSH/headless, WSL, Linux, Windows, mobile, and relay paths are unaffected.", + "coverageNotes": "A deterministic fake clock covers short-burst recovery, sustained session death, pending-timer and in-flight-probe suspension, event-trigger preemption, resolver corroboration, and shutdown cancellation. The production-only watch remains behind the macOS GUI launch flag; SSH/headless, WSL, Linux, Windows, mobile, and relay paths are unaffected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/11749", "https://github.com/stablyai/orca/issues/7936" ], - "invariant": "A GUI-spawned macOS daemon may retire for PAM rejections only after it previously accepted login wrapping, receives three conclusive rejections spanning at least one uninterrupted 120-second observation window, and observes explicitly unhealthy in-process resolver state. A conclusive acceptance or a sleep/App Nap-sized timer gap resets the rejection window, and client or PTY activity cannot shorten its scheduled backoff.", - "oracle": "Arm the watch with an accepted probe, inject three rejections over 20 seconds plus unhealthy resolver state, then inject client and PTY activity and require zero resolver reads, zero retirement calls, and exactly four probes until the 120-second boundary. Return acceptance at that boundary and require the daemon to survive. Jump wall time by one hour without firing the scheduled timer, require rejection evidence to restart after wake, and again recover without retirement. Separately keep rejecting through an uninterrupted boundary and require one retirement, while healthy or unknown resolver state suppresses it and stop aborts an in-flight resolver check.", + "invariant": "A GUI-spawned macOS daemon may retire for PAM rejections only after it previously accepted login wrapping, receives three conclusive rejections spanning at least one uninterrupted 120-second observation window, and observes explicitly unhealthy in-process resolver state. A conclusive acceptance or a sleep/App Nap-sized gap before or during a probe resets the rejection window to the periodic cadence, and client or PTY activity cannot shorten its scheduled backoff.", + "oracle": "Arm the watch with an accepted probe, inject three rejections over 20 seconds plus unhealthy resolver state, then inject client and PTY activity and require zero resolver reads, zero retirement calls, and exactly four probes until the 120-second boundary. Return acceptance at that boundary and require the daemon to survive. Jump wall time by one hour while a timer is pending and while a PAM probe promise is unresolved; require rejection evidence to restart after wake, zero probes for the next 119,999 milliseconds, and recovery without retirement in both cases. Repeatedly delay timers beyond the suspension threshold for one hour and require the periodic probe bound, one live timer, and zero resolver or retirement calls. Separately keep rejecting through an uninterrupted boundary and require one retirement, while healthy or unknown resolver state suppresses it and stop aborts an in-flight resolver check.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot" ], @@ -43,6 +43,8 @@ "assertions": [ "preserves the daemon when a short PAM rejection burst recovers after wake", "does not count a suspended timer gap as rejection evidence", + "does not count suspension during an in-flight probe as rejection evidence", + "backs off repeated timer lateness to the periodic probe cadence", "retires only after sustained conclusive rejections with a degraded resolver", "suppresses retirement while resolver health is healthy or unknown, then retires on explicit degradation", "stop prevents an in-flight resolver check from retiring the daemon" @@ -57,7 +59,7 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot", "result": "passed", "durationSeconds": 0.1, - "summary": "The focused state-machine suite passed 19 tests, including the field-shaped 20-second rejection burst, suspended-timer rebaselining, bounded recovery retry, sustained-death convergence, resolver suppression, trigger coalescing, and shutdown cancellation." + "summary": "The focused state-machine suite passed 21 tests, including the field-shaped 20-second rejection burst, pending-timer and in-flight-probe suspension rebaselining, repeated-lateness backoff, bounded recovery retry, sustained-death convergence, resolver suppression, trigger coalescing, and shutdown cancellation." } ], "runtimeBudget": { @@ -74,7 +76,7 @@ }, "performanceBudget": { "required": true, - "evidence": "The watch retains one timer and one in-flight probe. A rejection burst runs the existing two 10-second confirmation probes, then one recovery probe at the 120-second boundary; client and PTY activity cannot pull that deadline earlier. Resolver work is skipped until the time floor. No polling loop, startup await, session scan, renderer work, provider fanout, listener, or retained payload was added." + "evidence": "The watch retains one timer and one in-flight probe. A rejection burst runs the existing two 10-second confirmation probes, then one recovery probe at the 120-second boundary; client and PTY activity cannot pull that deadline earlier. Timer lateness and in-flight probe suspension both rebaseline evidence to the periodic cadence, preventing an App Nap retry loop, and resolver work is skipped until the time floor. No polling loop, startup await, session scan, renderer work, provider fanout, listener, or retained payload was added." }, "promotionCriteria": [ "Collect 100 consecutive focused CI passes or 14 days of soak history.", @@ -83,7 +85,7 @@ ], "knownGaps": [ "A real dead macOS GUI login session cannot be fabricated without ending the runner's login session; sustained-death coverage uses deterministic PAM and resolver oracles.", - "The two-minute production window and one-hour timer suspension are covered by fake-clock tests rather than a real sleep/wake run.", + "The two-minute production window and one-hour pending/in-flight suspension cases are covered by fake-clock tests rather than a real sleep/wake run.", "No multi-process aggregate throttle is added; each stale daemon independently obeys the same non-preemptible observation window." ], "demotionRule": "Keep experimental or demote if any activity trigger shortens the rejection window, a transient burst reaches resolver retirement authority, sustained dead-session evidence no longer converges, shutdown permits late retirement, or the focused gate flakes without an identified product or harness fault." diff --git a/src/main/daemon/login-session-watch-clock.ts b/src/main/daemon/login-session-watch-clock.ts new file mode 100644 index 00000000000..8082545815b --- /dev/null +++ b/src/main/daemon/login-session-watch-clock.ts @@ -0,0 +1,17 @@ +export type LoginSessionWatchClock = { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number +} + +export function createLoginSessionWatchClock(): LoginSessionWatchClock { + return { + setTimeout: (callback, delayMs) => { + const timer = setTimeout(callback, delayMs) + timer.unref() + return timer + }, + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + now: () => Date.now() + } +} diff --git a/src/main/daemon/login-session-watch-timing.ts b/src/main/daemon/login-session-watch-timing.ts new file mode 100644 index 00000000000..8bed6decba1 --- /dev/null +++ b/src/main/daemon/login-session-watch-timing.ts @@ -0,0 +1,19 @@ +export type LoginSessionWatchTiming = { + periodicProbeMs: number + rejectionRecheckMs: number + minimumRejectionSpanMs: number + ptyExitDebounceMs: number + clientActivityMinGapMs: number + minProbeGapMs: number +} + +export const DEFAULT_LOGIN_SESSION_WATCH_TIMING: LoginSessionWatchTiming = { + periodicProbeMs: 120_000, + rejectionRecheckMs: 10_000, + // Why: a full periodic window separates logout from short wake/PAM recovery bursts. + minimumRejectionSpanMs: 120_000, + ptyExitDebounceMs: 2_000, + // Why: steady reconnects must not turn client hellos into a PAM probe storm. + clientActivityMinGapMs: 30_000, + minProbeGapMs: 5_000 +} diff --git a/src/main/daemon/macos-login-session-death-watch.test.ts b/src/main/daemon/macos-login-session-death-watch.test.ts index fa983508ec5..1be9aecca64 100644 --- a/src/main/daemon/macos-login-session-death-watch.test.ts +++ b/src/main/daemon/macos-login-session-death-watch.test.ts @@ -158,7 +158,7 @@ describe('MacosLoginSessionDeathWatch', () => { it('does not count a suspended timer gap as rejection evidence', async () => { const readResolverHealth = vi.fn(async () => 'unhealthy' as const) const { watch, clock, onRetire, probe } = createWatch({ - outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED, ACCEPTED], + outcomes: [ACCEPTED, REJECTED, REJECTED, ACCEPTED], readResolverHealth }) watch.start() @@ -167,15 +167,69 @@ describe('MacosLoginSessionDeathWatch', () => { await clock.advance(120_000) clock.suspend(60 * 60 * 1000) await clock.advance(0) - await clock.advance(10_000) - await clock.advance(10_000) - expect(probe).toHaveBeenCalledTimes(5) + expect(probe).toHaveBeenCalledTimes(3) expect(readResolverHealth).not.toHaveBeenCalled() expect(onRetire).not.toHaveBeenCalled() - await clock.advance(100_000) - expect(probe).toHaveBeenCalledTimes(6) + await clock.advance(119_999) + expect(probe).toHaveBeenCalledTimes(3) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(4) + expect(onRetire).not.toHaveBeenCalled() + }) + + it('does not count suspension during an in-flight probe as rejection evidence', async () => { + let resolveProbe!: (outcome: LoginPreflightOutcome) => void + const deferredProbe = new Promise((resolve) => { + resolveProbe = resolve + }) + const probe = vi + .fn() + .mockResolvedValueOnce(ACCEPTED) + .mockResolvedValueOnce(REJECTED) + .mockReturnValueOnce(deferredProbe) + .mockResolvedValueOnce(ACCEPTED) + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire } = createWatch({ probeLoginSession: probe, readResolverHealth }) + watch.start() + await drainMicrotasks() + + await clock.advance(120_000) + await clock.advance(10_000) + clock.suspend(60 * 60 * 1000) + resolveProbe(REJECTED) + await drainMicrotasks() + + expect(probe).toHaveBeenCalledTimes(3) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + + await clock.advance(119_999) + expect(probe).toHaveBeenCalledTimes(3) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(4) + expect(onRetire).not.toHaveBeenCalled() + }) + + it('backs off repeated timer lateness to the periodic probe cadence', async () => { + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire, probe } = createWatch({ + outcomes: [ACCEPTED, ...Array.from({ length: 30 }, () => REJECTED)], + readResolverHealth + }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + + for (let i = 0; i < 27; i++) { + clock.suspend(135_001) + await clock.advance(0) + } + + expect(probe).toHaveBeenCalledTimes(29) + expect(clock.pendingCount()).toBe(1) + expect(readResolverHealth).not.toHaveBeenCalled() expect(onRetire).not.toHaveBeenCalled() }) diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts index ce803ff409b..1f529ff3e00 100644 --- a/src/main/daemon/macos-login-session-death-watch.ts +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -1,24 +1,17 @@ import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell' import type { DaemonFileLog } from './daemon-file-log' +import { + createLoginSessionWatchClock, + type LoginSessionWatchClock +} from './login-session-watch-clock' +import { + DEFAULT_LOGIN_SESSION_WATCH_TIMING, + type LoginSessionWatchTiming +} from './login-session-watch-timing' import type { SystemResolverHealth } from './types' // Why: retain count confirmation while the elapsed window below blocks short bursts. const REQUIRED_CONSECUTIVE_REJECTIONS = 3 -const PERIODIC_PROBE_MS = 120_000 -const REJECTION_RECHECK_MS = 10_000 -// Why: a full periodic window separates logout from short wake/PAM recovery bursts. -const MINIMUM_REJECTION_SPAN_MS = PERIODIC_PROBE_MS -const PTY_EXIT_DEBOUNCE_MS = 2_000 -// Why: a client hello right after login is the fastest death signal for a stale -// daemon, but steady reconnects must not turn hellos into a PAM probe storm. -const CLIENT_ACTIVITY_MIN_GAP_MS = 30_000 -const MIN_PROBE_GAP_MS = 5_000 - -type WatchClock = { - setTimeout(callback: () => void, delayMs: number): unknown - clearTimeout(handle: unknown): void - now(): number -} export type MacosLoginSessionDeathWatchOptions = { /** Fresh PAM probe (cache-bypassing); null when the login wrapper doesn't apply on this host. */ @@ -31,15 +24,8 @@ export type MacosLoginSessionDeathWatchOptions = { }) => void log: DaemonFileLog /** Direct-construction seam for deterministic tests; production uses real timers. */ - clock?: WatchClock - timing?: Partial<{ - periodicProbeMs: number - rejectionRecheckMs: number - minimumRejectionSpanMs: number - ptyExitDebounceMs: number - clientActivityMinGapMs: number - minProbeGapMs: number - }> + clock?: LoginSessionWatchClock + timing?: Partial } /** @@ -63,7 +49,7 @@ export class MacosLoginSessionDeathWatch { private readonly readResolverHealth: MacosLoginSessionDeathWatchOptions['readResolverHealth'] private readonly onRetire: MacosLoginSessionDeathWatchOptions['onRetire'] private readonly log: DaemonFileLog - private readonly clock: WatchClock + private readonly clock: LoginSessionWatchClock private readonly periodicProbeMs: number private readonly rejectionRecheckMs: number private readonly minimumRejectionSpanMs: number @@ -91,21 +77,16 @@ export class MacosLoginSessionDeathWatch { this.readResolverHealth = opts.readResolverHealth this.onRetire = opts.onRetire this.log = opts.log - this.clock = opts.clock ?? { - setTimeout: (callback, delayMs) => { - const timer = setTimeout(callback, delayMs) - timer.unref() - return timer - }, - clearTimeout: (handle) => clearTimeout(handle as ReturnType), - now: () => Date.now() - } - this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS - this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS - this.minimumRejectionSpanMs = opts.timing?.minimumRejectionSpanMs ?? MINIMUM_REJECTION_SPAN_MS - this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS - this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS - this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS + this.clock = opts.clock ?? createLoginSessionWatchClock() + const defaults = DEFAULT_LOGIN_SESSION_WATCH_TIMING + this.periodicProbeMs = opts.timing?.periodicProbeMs ?? defaults.periodicProbeMs + this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? defaults.rejectionRecheckMs + this.minimumRejectionSpanMs = + opts.timing?.minimumRejectionSpanMs ?? defaults.minimumRejectionSpanMs + this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? defaults.ptyExitDebounceMs + this.clientActivityMinGapMs = + opts.timing?.clientActivityMinGapMs ?? defaults.clientActivityMinGapMs + this.minProbeGapMs = opts.timing?.minProbeGapMs ?? defaults.minProbeGapMs } start(): void { @@ -222,10 +203,12 @@ export class MacosLoginSessionDeathWatch { } const timerGapMs = scheduledAtMs === undefined ? 0 : Math.max(0, this.clock.now() - scheduledAtMs) + let rejectionWindowRebased = false if (this.consecutiveRejections > 0 && timerGapMs > this.minProbeGapMs * 3) { // Why: sleep/App Nap pauses probes; elapsed wall time is not rejection evidence. this.consecutiveRejections = 0 this.firstRejectionAtMs = null + rejectionWindowRebased = true this.log.log('login-session-rejection-window-reset', { cause: 'timer-gap', timerGapMs @@ -242,7 +225,8 @@ export class MacosLoginSessionDeathWatch { return } this.probeInFlight = true - this.lastProbeStartedAtMs = this.clock.now() + const probeStartedAtMs = this.clock.now() + this.lastProbeStartedAtMs = probeStartedAtMs const abortController = new AbortController() this.probeAbortController = abortController try { @@ -250,6 +234,17 @@ export class MacosLoginSessionDeathWatch { if (this.stopped || this.retired) { return } + const probeGapMs = Math.max(0, this.clock.now() - probeStartedAtMs) + if (this.consecutiveRejections > 0 && probeGapMs > this.minProbeGapMs * 3) { + // Why: sleep can suspend an in-flight PAM probe after the timer-gap check. + this.consecutiveRejections = 0 + this.firstRejectionAtMs = null + rejectionWindowRebased = true + this.log.log('login-session-rejection-window-reset', { + cause: 'probe-gap', + probeGapMs + }) + } if (outcome === null) { // Why: no wrapper machinery means no PAM oracle — watching would only ever misfire. this.log.log('login-session-watch-disabled', { trigger }) @@ -284,7 +279,9 @@ export class MacosLoginSessionDeathWatch { rejections: this.consecutiveRejections }) if (this.consecutiveRejections < REQUIRED_CONSECUTIVE_REJECTIONS) { - this.scheduleNextProbe(this.rejectionRecheckMs) + this.scheduleNextProbe( + rejectionWindowRebased ? this.periodicProbeMs : this.rejectionRecheckMs + ) return } const rejectionSpanMs = this.clock.now() - this.firstRejectionAtMs diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx index 14a1967a329..50190295daa 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -76,6 +76,15 @@ function installAnimationFrameController(): { } } +async function flushPortalFramesUntil( + frames: ReturnType, + settled: () => boolean +): Promise { + for (let frame = 0; frame < 4 && !settled(); frame += 1) { + await frames.flush() + } +} + // Models the tab-root DOM and sibling hiding emitted by a portaled TerminalPane. function renderPortaledTerminalPane(target: HTMLElement, tabId: string, leafIds: string[]): void { const isolatedLeafId = leafIds[0] @@ -345,7 +354,7 @@ describe('Activity portal pane switching', () => { await act(async () => { root.render() }) - await frames.flush() + await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'unavailable') expect(statuses.at(-1)).toBe('unavailable') // Feed each observed DOM state separately so MutationObserver cannot coalesce the flips. @@ -362,7 +371,7 @@ describe('Activity portal pane switching', () => { buildRoot('ready') await Promise.resolve() }) - await frames.flush() + await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'ready') expect(statuses.at(-1)).toBe('ready') }) }) From 5c0195af64961092150ef76666e51f47dcf13517 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:55:58 -0700 Subject: [PATCH 8/9] Bound remote watcher fan-out and defer File Explorer refreshes (#11908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * batch remote watcher events and defer File Explorer refreshes Remote filesystem watcher events now batch with the shared 150ms trailing and 500ms max-wait window, coalescing per-path like local events. File Explorer tree and directory refreshes are scheduled with debounce and transport-aware concurrency caps (16 local, 8 runtime, 4 SSH). Stale directory cache tracking prevents trusting collapsed listings skipped by full refresh; they are re-read on re-expansion. Relay implements a 15-minute idle-only grace cap for zero-PTY relays via PTY pool lifecycle tracking, independent of explicitly configured grace time. * fix(watch/relay): bound remote watcher fan-out and read the live relay grace Three P1 fixes from the SSH/remote freeze audit: - Remote watchers now debounce on the same 150/500 window as local ones (finding D), and every teardown path drops the trailing flush timer instead of letting it fire into a dead watch. The deferred send is wrapped so a frame disposed mid-window can't escape as a fatal main-process exception. - File Explorer refreshes are scheduled and concurrency-capped rather than fanned out unbounded over expanded dirs (finding C). Local transports use a zero window, since main already coalesced the burst. - relay.startGrace reads ptyHandler.configuredGraceTimeMs instead of the launch-time argv closure, so a grace raised after launch is honored. The branch selection moves to relay-grace-branch.ts because relay.ts has no exports and calls main() at import, making it untestable. Consequence: a host-sleep relay holding zero PTYs now exits after the idle cap. Pinned by test and documented in docs/reference/relay-grace-time-reconfiguration.md. Also drops the duplicated 150/500/5000 constants in the runtime-RPC batcher in favor of the shared window module. * docs(relay): correct grace-reconfiguration line numbers after the relay.ts edit Co-authored-by: Orca * refactor(file-explorer): use useMemo for paths; remove relay reference Replace manual ref-based caching with proper React hooks for content-stable path memoization. Remove outdated relay grace-time reference documentation from code review cycle. * rm design doc * fix(remote-watcher): prevent stranded timer after close An in-flight provider receive can land after the batch is torn down. Without a guard, pushing events to a closed batch would re-arm a timer that would never be cleared, stranding the task indefinitely. Track the closed state and skip pushes after close(). Relay.ts comment clarifies why pool watches remain registered during grace-period shutdown deferral — the socket server stays listening so a reconnecting client can cancel the grace and resume. --------- Co-authored-by: Orca --- .gitignore | 1 + .../filesystem-watcher-large-batch.test.ts | 62 +++ .../filesystem-watcher-remote-batch.test.ts | 191 +++++++++ ...system-watcher-remote-cancellation.test.ts | 3 +- src/main/ipc/filesystem-watcher.test.ts | 31 +- src/main/ipc/filesystem-watcher.ts | 77 +++- .../ipc/remote-watcher-event-batch.test.ts | 270 +++++++++++++ src/main/ipc/remote-watcher-event-batch.ts | 147 +++++++ .../rpc/methods/file-watch-event-batcher.ts | 15 +- src/relay/pty-handler.test.ts | 189 +++++++++ src/relay/pty-handler.ts | 74 +++- src/relay/relay-grace-branch.test.ts | 100 +++++ src/relay/relay-grace-branch.ts | 63 +++ src/relay/relay.ts | 91 ++++- src/relay/subprocess.test.ts | 255 ++++++++++++ .../components/right-sidebar/FileExplorer.tsx | 13 +- ...le-explorer-expanded-dirs-refresh.test.ts} | 146 ++++++- .../file-explorer-expanded-dirs-refresh.ts | 137 +++++++ .../file-explorer-refresh-concurrency.test.ts | 36 ++ .../file-explorer-refresh-concurrency.ts | 31 ++ .../file-explorer-stale-dir-cache.test.ts | 53 +++ .../file-explorer-stale-dir-cache.ts | 34 ++ .../right-sidebar/file-explorer-types.ts | 9 + .../file-explorer-watch-reconcile.test.ts | 29 ++ ...e-explorer-watch-refresh-scheduler.test.ts | 382 ++++++++++++++++++ .../file-explorer-watch-refresh-scheduler.ts | 152 +++++++ .../useFileExplorerManualRefresh.ts | 2 +- .../useFileExplorerTree.stale-dirs.test.tsx | 155 +++++++ .../right-sidebar/useFileExplorerTree.ts | 174 +++----- ...orerVisibleRowProjection.debounce.test.tsx | 49 ++- .../useFileExplorerVisibleRowProjection.ts | 27 +- .../right-sidebar/useFileExplorerWatch.ts | 44 +- src/shared/filesystem-watch-batch-window.ts | 5 + 33 files changed, 2847 insertions(+), 200 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-remote-batch.test.ts create mode 100644 src/main/ipc/remote-watcher-event-batch.test.ts create mode 100644 src/main/ipc/remote-watcher-event-batch.ts create mode 100644 src/relay/relay-grace-branch.test.ts create mode 100644 src/relay/relay-grace-branch.ts rename src/renderer/src/components/right-sidebar/{useFileExplorerTree.test.ts => file-explorer-expanded-dirs-refresh.test.ts} (58%) create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.ts create mode 100644 src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx create mode 100644 src/shared/filesystem-watch-batch-window.ts diff --git a/.gitignore b/.gitignore index 51306ff2066..2918ae32a69 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md +!docs/reference/relay-grace-time-reconfiguration.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 01f7ccc90cc..80d45c90e43 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -32,6 +32,11 @@ import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesyste import { stat } from 'node:fs/promises' import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' import type { Event as WatcherEvent } from '@parcel/watcher' +import type { FsChangedPayload } from '../../shared/types' +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../shared/filesystem-watch-batch-window' type HandlerMap = Record Promise | unknown> @@ -111,4 +116,61 @@ describe('local filesystem watcher large batches', () => { await closeAllWatchers() vi.useRealTimers() }) + + it('still coalesces a local burst on the shared trailing window', async () => { + vi.useFakeTimers() + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined + vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe: vi.fn() } as never + }) + const worktreePath = resolve('/tmp/repo') + const filePath = join(worktreePath, 'a.ts') + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath }) + watcherCallback?.(null, [{ type: 'update', path: filePath }]) + watcherCallback?.(null, [{ type: 'update', path: filePath }]) + + await vi.advanceTimersByTimeAsync(WATCH_BATCH_TRAILING_MS - 1) + expect(sender.send).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath, + events: [{ kind: 'update', absolutePath: filePath, isDirectory: true }] + }) + await closeAllWatchers() + vi.useRealTimers() + }) + + it('still flushes a sustained local stream at the shared max wait', async () => { + vi.useFakeTimers() + vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined + vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { + watcherCallback = callback as typeof watcherCallback + return { unsubscribe: vi.fn() } as never + }) + const worktreePath = resolve('/tmp/repo') + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']({ sender }, { worktreePath }) + // Step under the trailing window so only the max wait can force a flush. + const step = 100 + for (let elapsed = 0; elapsed <= WATCH_BATCH_MAX_WAIT_MS; elapsed += step) { + watcherCallback?.(null, [{ type: 'update', path: join(worktreePath, `f-${elapsed}.ts`) }]) + if (elapsed < WATCH_BATCH_MAX_WAIT_MS) { + expect(sender.send).not.toHaveBeenCalled() + } + await vi.advanceTimersByTimeAsync(step) + } + + expect(sender.send).toHaveBeenCalledTimes(1) + expect((sender.send.mock.calls[0][1] as FsChangedPayload).events).toHaveLength(6) + await closeAllWatchers() + vi.useRealTimers() + }) }) diff --git a/src/main/ipc/filesystem-watcher-remote-batch.test.ts b/src/main/ipc/filesystem-watcher-remote-batch.test.ts new file mode 100644 index 00000000000..6d964c2b2d4 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-remote-batch.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FsChangeEvent } from '../../shared/types' + +const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + providerRegistrationListeners: new Set<(connectionId: string) => void>() + }) +) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock, + onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { + providerRegistrationListeners.add(listener) + return () => providerRegistrationListeners.delete(listener) + } +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> +type WatchCallback = (events: FsChangeEvent[]) => void + +const WORKTREE_PATH = '/home/me/repo' +const WATCH_ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } + +describe('remote filesystem watcher batching', () => { + const handlers: HandlerMap = {} + const watchCallbacks: WatchCallback[] = [] + + function makeSender(overrides: Partial<{ isDestroyed: () => boolean }> = {}) { + return { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1, ...overrides } + } + + beforeEach(async () => { + vi.useRealTimers() + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + watchCallbacks.length = 0 + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + getSshFilesystemProviderMock.mockReturnValue({ + watch: vi.fn(async (_root: string, callback: WatchCallback) => { + watchCallbacks.push(callback) + return vi.fn() + }) + }) + }) + + it('coalesces a burst of remote events into a single fs:changed send', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + for (let i = 0; i < 50; i++) { + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + } + expect(sender.send).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(150) + + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: WORKTREE_PATH, + events: [{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }] + }) + await closeAllWatchers() + vi.useRealTimers() + }) + + // An atomic replace (delete+create) followed by an rm inside one window must still tombstone the path. + it('sends the net delete when a remote replace is removed in the same window', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]([ + { kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }, + { kind: 'create', absolutePath: `${WORKTREE_PATH}/dist/app.js` } + ]) + watchCallbacks[0]([{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }]) + await vi.advanceTimersByTimeAsync(150) + + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: WORKTREE_PATH, + events: [{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }] + }) + await closeAllWatchers() + vi.useRealTimers() + }) + + it('collapses an oversized remote burst into one overflow payload', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]( + Array.from( + { length: 6_000 }, + (_unused, index): FsChangeEvent => ({ + kind: 'update', + absolutePath: `${WORKTREE_PATH}/file-${index}.ts` + }) + ) + ) + await vi.advanceTimersByTimeAsync(150) + + expect(sender.send).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: WORKTREE_PATH, + events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }] + }) + await closeAllWatchers() + vi.useRealTimers() + }) + + it('sends nothing when the worktree is unwatched before the flush deadline', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + await handlers['fs:unwatchWorktree']({ sender }, WATCH_ARGS) + await vi.advanceTimersByTimeAsync(1_000) + + expect(sender.send).not.toHaveBeenCalled() + await closeAllWatchers() + vi.useRealTimers() + }) + + it('sends nothing when all watchers close before the flush deadline', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + await closeAllWatchers() + await vi.advanceTimersByTimeAsync(1_000) + + expect(sender.send).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('skips a WebContents destroyed while the batch was buffering', async () => { + vi.useFakeTimers() + let destroyed = false + const sender = makeSender({ isDestroyed: () => destroyed }) + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + destroyed = true + await vi.advanceTimersByTimeAsync(1_000) + + expect(sender.send).not.toHaveBeenCalled() + await closeAllWatchers() + vi.useRealTimers() + }) + + it('drops a stale flush once a newer install owns the key', async () => { + vi.useFakeTimers() + const sender = makeSender() + await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS) + + for (const listener of providerRegistrationListeners) { + listener('conn-1') + } + await vi.advanceTimersByTimeAsync(1_000) + expect(watchCallbacks).toHaveLength(2) + sender.send.mockClear() + + // The replaced transport's callback still holds its own batch; its flush must not reach the renderer. + watchCallbacks[0]([{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }]) + await vi.advanceTimersByTimeAsync(1_000) + + expect(sender.send).not.toHaveBeenCalled() + await closeAllWatchers() + vi.useRealTimers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts index f4af15588be..6a67f3a8fb7 100644 --- a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -113,7 +113,8 @@ describe('remote filesystem watcher cancellation', () => { await Promise.all([first, second]) expect(watchMock).toHaveBeenCalledTimes(2) secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) - expect(secondSender.send).toHaveBeenCalledTimes(1) + // Why: remote fs:changed rides the shared debounce window, so the send lands a flush later. + await vi.waitFor(() => expect(secondSender.send).toHaveBeenCalledTimes(1)) handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) expect(secondUnwatch).toHaveBeenCalledTimes(1) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index b2f83baaf22..1e9e3cf509a 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -58,9 +58,18 @@ import { WatcherChildCapacityError } from './parcel-watcher-child-registry' import { acquireWatcherRemovalGate } from './watcher-removal-gate' +import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window' type HandlerMap = Record Promise | unknown> +/** Remote fs:changed rides the shared debounce window, so drain it before asserting sends. */ +const emitRemote = async (onEvents: (e: unknown[]) => void, events: unknown[]) => { + onEvents(events) + await (vi.isFakeTimers() + ? vi.advanceTimersByTimeAsync(WATCH_BATCH_TRAILING_MS) + : new Promise((resolve) => setTimeout(resolve, WATCH_BATCH_TRAILING_MS + 25))) +} + describe('registerFilesystemWatcherHandlers', () => { const handlers: HandlerMap = {} const originalPlatform = process.platform @@ -234,7 +243,7 @@ describe('registerFilesystemWatcherHandlers', () => { onTerminalError: expect.any(Function) }) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendMock).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', events: [{ path: '/home/me/repo/file.txt', type: 'update' }] @@ -329,7 +338,7 @@ describe('registerFilesystemWatcherHandlers', () => { expect(watchMock).toHaveBeenCalledTimes(2) const recoveredEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void - recoveredEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + await emitRemote(recoveredEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) expect(senderOne.send).not.toHaveBeenCalled() expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', @@ -369,7 +378,7 @@ describe('registerFilesystemWatcherHandlers', () => { ) const reinstalledEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void - reinstalledEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + await emitRemote(reinstalledEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] @@ -552,7 +561,7 @@ describe('registerFilesystemWatcherHandlers', () => { expect(watchMock).toHaveBeenCalledTimes(1) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(1) expect(sendTwo).toHaveBeenCalledTimes(1) @@ -621,7 +630,7 @@ describe('registerFilesystemWatcherHandlers', () => { events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] }) const replacementEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void - replacementEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + await emitRemote(replacementEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) expect(sender.send).toHaveBeenLastCalledWith('fs:changed', { worktreePath: '/home/me/repo', events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] @@ -716,7 +725,7 @@ describe('registerFilesystemWatcherHandlers', () => { 'still watched by another client' ) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(1) await handlers['fs:watchWorktree']( @@ -724,7 +733,7 @@ describe('registerFilesystemWatcherHandlers', () => { { worktreePath: '/home/me/repo', connectionId: 'conn-1' } ) expect(watchMock).toHaveBeenCalledTimes(1) - onEvents([{ path: '/home/me/repo/file-2.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file-2.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(2) expect(sendTwo).toHaveBeenCalledTimes(1) @@ -763,7 +772,7 @@ describe('registerFilesystemWatcherHandlers', () => { await Promise.all([firstWatch, secondWatch]) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(1) expect(sendTwo).toHaveBeenCalledTimes(1) @@ -809,7 +818,7 @@ describe('registerFilesystemWatcherHandlers', () => { await Promise.all([firstWatch, secondWatch]) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendOne).toHaveBeenCalledTimes(1) expect(sendTwo).not.toHaveBeenCalled() @@ -855,7 +864,7 @@ describe('registerFilesystemWatcherHandlers', () => { expect(unwatchMock).toHaveBeenCalledTimes(1) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sender.send).not.toHaveBeenCalled() }) @@ -886,7 +895,7 @@ describe('registerFilesystemWatcherHandlers', () => { await Promise.all([firstWatch, secondWatch]) const onEvents = watchMock.mock.calls[0][1] - onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(senderOne.send).not.toHaveBeenCalled() expect(senderTwo.send).toHaveBeenCalledTimes(1) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 58401722494..571f6c46b3a 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -16,6 +16,14 @@ import { onSshFilesystemProviderRegistered } from '../providers/ssh-filesystem-dispatch' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' +import { + createRemoteWatcherEventBatch, + type RemoteWatcherEventBatch +} from './remote-watcher-event-batch' +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../shared/filesystem-watch-batch-window' import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process' import { isWatcherProcessFailure } from './parcel-watcher-process-failure' import { @@ -32,11 +40,6 @@ import { // Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows). import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' -// ── Debounce helpers ───────────────────────────────────────────────── - -const DEBOUNCE_TRAILING_MS = 150 -const DEBOUNCE_MAX_WAIT_MS = 500 - // ── Per-root watcher state ─────────────────────────────────────────── // WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape. @@ -349,7 +352,7 @@ function scheduleBatchFlush(root: WatchedRoot): void { } // If we've exceeded the max wait, flush immediately - if (now - root.batch.firstEventAt >= DEBOUNCE_MAX_WAIT_MS) { + if (now - root.batch.firstEventAt >= WATCH_BATCH_MAX_WAIT_MS) { if (root.batch.timer) { clearTimeout(root.batch.timer) } @@ -361,7 +364,7 @@ function scheduleBatchFlush(root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) } - root.batch.timer = setTimeout(() => void flushBatch(root), DEBOUNCE_TRAILING_MS) + root.batch.timer = setTimeout(() => void flushBatch(root), WATCH_BATCH_TRAILING_MS) } // ── Watcher creation ───────────────────────────────────────────────── @@ -944,6 +947,8 @@ type RemoteWatcherState = { unwatch: () => void listeners: Map installToken: RemoteWatcherInstallToken + /** Held so every teardown can drop the trailing flush timer instead of letting it fire into a dead watch. */ + batch: RemoteWatcherEventBatch } type RemoteWatcherInstallToken = { @@ -1049,6 +1054,7 @@ export async function closeRemoteWatcherForWorktreePath( await (provider?.closeWatch ? provider.closeWatch(worktreePath) : Promise.resolve(state?.unwatch())) + state?.batch.close() remoteWatchers.delete(key) loggedUnavailableRemoteWatchers.delete(key) } @@ -1161,6 +1167,7 @@ function releaseRemoteWatchListener(key: string, senderId: number): void { return } state.unwatch() + state.batch.close() remoteWatchers.delete(key) } @@ -1287,23 +1294,47 @@ async function doInstallRemoteWatcher( cancelToken: RemoteWatcherInstallToken ): Promise { let unwatch: () => void - try { - unwatch = await provider.watch( - worktreePath, - (events) => { - const state = remoteWatchers.get(key) - if (!state) { - return + // Why: the abandon paths below close the batch directly; once it is stored on the remoteWatchers + // entry, every later teardown closes it through that handle. deliver's state/installToken guard + // still covers a flush that races a close. + const batch = createRemoteWatcherEventBatch({ + rootPath: worktreePath, + trailingMs: WATCH_BATCH_TRAILING_MS, + maxWaitMs: WATCH_BATCH_MAX_WAIT_MS, + maxEvents: MAX_BATCHED_WATCHER_EVENTS, + deliver: (events) => { + const state = remoteWatchers.get(key) + // Why: buffering defers delivery past install, so a bare non-null check would let a stale flush + // land on a different install generation. + if (!state || state.installToken !== cancelToken) { + return + } + for (const listener of state.listeners.values()) { + if (listener.isDestroyed()) { + continue } - for (const listener of state.listeners.values()) { - if (listener.isDestroyed()) { - continue - } + try { listener.send('fs:changed', { worktreePath, events } satisfies FsChangedPayload) + } catch (err) { + // Why: batching moved this send out of the SSH mux's notification try/catch into a bare + // timer, so a frame disposed mid-window would escape as a fatal main-process exception. + console.warn( + `[filesystem-watch] failed to deliver remote fs:changed for ${worktreePath}: ${ + err instanceof Error ? err.message : String(err) + }` + ) } + } + } + }) + try { + unwatch = await provider.watch( + worktreePath, + (events) => { + batch.push(events) }, { signal: cancelToken.abortController.signal, @@ -1312,6 +1343,9 @@ async function doInstallRemoteWatcher( } ) } catch (err) { + // Why: the provider can fire its callback before rejecting (an aborted reinstall), so this + // abandon path owes the same close as the two below — nothing else holds the batch. + batch.close() if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) { return 'cancelled' } @@ -1326,6 +1360,7 @@ async function doInstallRemoteWatcher( Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed()) ) if (cancelToken.cancelled || liveListeners.size === 0) { + batch.close() try { unwatch() } catch (err) { @@ -1334,9 +1369,10 @@ async function doInstallRemoteWatcher( return 'cancelled' } if (cancelToken.terminalError) { + batch.close() return 'unavailable' } - remoteWatchers.set(key, { unwatch, listeners: liveListeners, installToken: cancelToken }) + remoteWatchers.set(key, { unwatch, listeners: liveListeners, installToken: cancelToken, batch }) for (const listener of liveListeners.values()) { registerSenderCleanup(listener) } @@ -1357,6 +1393,7 @@ function handleRemoteWatcherTerminalError( return } remoteWatchers.delete(key) + state.batch.close() if (remoteWatchersClosed || suspendedRemoteWatcherListeners.has(key)) { return } @@ -1779,6 +1816,7 @@ function reinstallRemoteWatchersForConnection(connectionId: string): void { const stale = remoteWatchers.get(key) if (stale) { remoteWatchers.delete(key) + stale.batch.close() try { stale.unwatch() } catch { @@ -1912,6 +1950,7 @@ export async function closeAllWatchers(): Promise { // Why: remote watchers are separate from local @parcel/watcher subs; unwatch here or the relay keeps polling FS after shutdown. for (const [key, state] of remoteWatchers) { + state.batch.close() try { state.unwatch() } catch (err) { diff --git a/src/main/ipc/remote-watcher-event-batch.test.ts b/src/main/ipc/remote-watcher-event-batch.test.ts new file mode 100644 index 00000000000..b659c446aed --- /dev/null +++ b/src/main/ipc/remote-watcher-event-batch.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FsChangeEvent } from '../../shared/types' +import { createRemoteWatcherEventBatch } from './remote-watcher-event-batch' + +const ROOT = '/home/u/repo' + +function makeBatch(deliver: (events: FsChangeEvent[]) => void, maxEvents = 5_000) { + return createRemoteWatcherEventBatch({ + rootPath: ROOT, + deliver, + trailingMs: 150, + maxWaitMs: 500, + maxEvents + }) +} + +describe('createRemoteWatcherEventBatch', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses many pushes inside the trailing window into one delivery', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + for (let i = 0; i < 50; i++) { + batch.push([{ kind: 'update', absolutePath: `${ROOT}/file-${i}.ts` }]) + vi.advanceTimersByTime(1) + } + expect(deliver).not.toHaveBeenCalled() + + vi.advanceTimersByTime(150) + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toHaveLength(50) + }) + + it('flushes at the max wait when pushes never stop', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + for (let i = 0; i < 10; i++) { + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + vi.advanceTimersByTime(100) + } + + expect(deliver).toHaveBeenCalled() + expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + }) + + it('emits both events for delete then create, delete first', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` } + ]) + }) + + it('drops both events for create then delete', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'create', absolutePath: `${ROOT}/tmp.ts` }, + { kind: 'delete', absolutePath: `${ROOT}/tmp.ts` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver).not.toHaveBeenCalled() + }) + + it('clears a stale delete when a later update is known to be a file', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` }, + { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + ]) + }) + + it('keeps the delete when a later update has unknown isDirectory', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/dist` }, + { kind: 'create', absolutePath: `${ROOT}/dist` }, + { kind: 'update', absolutePath: `${ROOT}/dist` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'delete', absolutePath: `${ROOT}/dist` }, + { kind: 'update', absolutePath: `${ROOT}/dist` } + ]) + }) + + it('emits the net delete for delete then create then delete', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/dist/app.js` }, + { kind: 'create', absolutePath: `${ROOT}/dist/app.js` }, + { kind: 'delete', absolutePath: `${ROOT}/dist/app.js` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'delete', absolutePath: `${ROOT}/dist/app.js` } + ]) + }) + + it('re-hoists the delete when the window ends on a recreate', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` }, + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, + { kind: 'create', absolutePath: `${ROOT}/a.ts` } + ]) + }) + + it('keeps only the last update for one path', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }]) + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false }]) + vi.advanceTimersByTime(150) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + ]) + }) + + it('discards the buffer and delivers one overflow when an overflow event arrives', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + batch.push([{ kind: 'overflow', absolutePath: ROOT }]) + batch.push([{ kind: 'update', absolutePath: `${ROOT}/b.ts` }]) + vi.advanceTimersByTime(150) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'overflow', absolutePath: ROOT }]) + }) + + it('latches overflow once the accumulated batch exceeds maxEvents', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver, 100) + + for (let i = 0; i < 3; i++) { + batch.push( + Array.from( + { length: 50 }, + (_unused, index): FsChangeEvent => ({ + kind: 'update', + absolutePath: `${ROOT}/wave-${i}-${index}.ts` + }) + ) + ) + } + vi.advanceTimersByTime(150) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver.mock.calls[0][0]).toEqual([{ kind: 'overflow', absolutePath: ROOT }]) + }) + + it('never invents isDirectory for events that arrive without it', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'create', absolutePath: `${ROOT}/dir` }]) + vi.advanceTimersByTime(150) + + const [delivered] = deliver.mock.calls[0][0] as FsChangeEvent[] + expect(delivered.isDirectory).toBeUndefined() + expect('isDirectory' in delivered).toBe(false) + }) + + it('passes an unknown kind through verbatim after the coalesced set', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([ + { kind: 'rename', absolutePath: `${ROOT}/new.ts`, oldAbsolutePath: `${ROOT}/old.ts` }, + { kind: 'update', absolutePath: `${ROOT}/a.ts` } + ]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: `${ROOT}/a.ts` }, + { kind: 'rename', absolutePath: `${ROOT}/new.ts`, oldAbsolutePath: `${ROOT}/old.ts` } + ]) + }) + + it('delivers nothing after close', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + batch.close() + vi.advanceTimersByTime(1_000) + + expect(deliver).not.toHaveBeenCalled() + }) + + it('ignores a push that lands after close instead of arming a timer', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.close() + batch.push([{ kind: 'update', absolutePath: `${ROOT}/a.ts` }]) + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(1_000) + expect(deliver).not.toHaveBeenCalled() + }) + + // Why: the host platform is irrelevant — the coalesce key must stay shape-based, so a case-sensitive + // remote pair must survive it on any host, and neither path may pick up drive/backslash rewriting. + it('keeps case-distinct remote POSIX paths separate and byte-identical', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + + batch.push([{ kind: 'update', absolutePath: '/home/u/A' }]) + batch.push([{ kind: 'update', absolutePath: '/home/u/a' }]) + vi.advanceTimersByTime(150) + + const delivered = deliver.mock.calls[0][0] as FsChangeEvent[] + expect(delivered).toEqual([ + { kind: 'update', absolutePath: '/home/u/A' }, + { kind: 'update', absolutePath: '/home/u/a' } + ]) + for (const event of delivered) { + expect(event.absolutePath).not.toContain('\\') + expect(event.absolutePath).not.toMatch(/^[a-zA-Z]:/) + } + }) +}) diff --git a/src/main/ipc/remote-watcher-event-batch.ts b/src/main/ipc/remote-watcher-event-batch.ts new file mode 100644 index 00000000000..86f10e81d96 --- /dev/null +++ b/src/main/ipc/remote-watcher-event-batch.ts @@ -0,0 +1,147 @@ +import type { FsChangeEvent } from '../../shared/types' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +export type RemoteWatcherEventBatchOptions = { + rootPath: string + deliver: (events: FsChangeEvent[]) => void + trailingMs: number + maxWaitMs: number + maxEvents: number +} + +export type RemoteWatcherEventBatch = { + push: (events: FsChangeEvent[]) => void + close: () => void +} + +// Mirrors coalesceEvents in filesystem-watcher.ts, with two deliberate divergences: a hoisted delete +// survives a later event of unknown isDirectory, so delete→create→update over-emits a delete; and +// delete→create→delete emits that delete, which local drops — the path is gone, so the renderer needs it. +function coalesceRemoteEvents(raw: FsChangeEvent[]): FsChangeEvent[] { + const lastByKey = new Map() + const deleteBeforeCreate = new Map() + const passthrough: FsChangeEvent[] = [] + + for (const event of raw) { + if (event.kind !== 'create' && event.kind !== 'update' && event.kind !== 'delete') { + // Why: 'rename' has no producer on this transport; forward it untouched rather than invent a rule. + passthrough.push(event) + continue + } + // Why: key on the comparison form, never node path.resolve — resolving a remote POSIX path on a + // Windows host mints C:\home\u\f, which the renderer's watch canonicalizer then rejects outright. + // Residual cost: its NFC fold merges canonically equivalent byte-distinct names on a POSIX host. + const key = normalizeRuntimePathForComparison(event.absolutePath) + const prev = lastByKey.get(key) + + if (prev) { + if (prev.kind === 'delete' && event.kind === 'create') { + deleteBeforeCreate.set(key, prev) + } + if (prev.kind === 'create' && event.kind === 'delete') { + // Why: cancel only a path created and removed entirely inside the window; an earlier delete means + // the file predates it, so the window nets out to a delete the renderer still has to act on. + const netNoOp = !deleteBeforeCreate.delete(key) + if (netNoOp) { + lastByKey.delete(key) + continue + } + } + } + + lastByKey.set(key, event) + + // Why: drop the hoisted delete only when the later event proves the path is a live file, or repeats the + // delete. Remote events carry no isDirectory, and discarding it there strands a dead dir's cached subtree. + if (event.kind === 'delete' || (event.kind === 'update' && event.isDirectory === false)) { + deleteBeforeCreate.delete(key) + } + } + + return [...deleteBeforeCreate.values(), ...lastByKey.values(), ...passthrough] +} + +export function createRemoteWatcherEventBatch({ + rootPath, + deliver, + trailingMs, + maxWaitMs, + maxEvents +}: RemoteWatcherEventBatchOptions): RemoteWatcherEventBatch { + let buffered: FsChangeEvent[] = [] + let overflowed = false + let firstEventAt = 0 + let timer: ReturnType | null = null + let closed = false + + function flush(): void { + if (timer) { + clearTimeout(timer) + timer = null + } + const pending = buffered + const latched = overflowed + buffered = [] + overflowed = false + firstEventAt = 0 + + if (latched) { + deliver([{ kind: 'overflow', absolutePath: rootPath }]) + return + } + const coalesced = coalesceRemoteEvents(pending) + if (coalesced.length > 0) { + deliver(coalesced) + } + } + + function schedule(): void { + const now = Date.now() + if (firstEventAt === 0) { + firstEventAt = now + } + if (now - firstEventAt >= maxWaitMs) { + flush() + return + } + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(flush, trailingMs) + } + + return { + push(events) { + // Why: an in-flight provider receive can land after teardown; re-arming there would strand a timer. + if (closed) { + return + } + if (!overflowed) { + if ( + buffered.length + events.length > maxEvents || + events.some((event) => event.kind === 'overflow') + ) { + // Why: past the cap precision only burns memory; one overflow buys the same conservative refresh. + buffered = [] + overflowed = true + } else { + // Why: a deletion storm can exceed V8's argument limit for push(...events). + for (const event of events) { + buffered.push(event) + } + } + } + schedule() + }, + close() { + closed = true + if (timer) { + clearTimeout(timer) + timer = null + } + buffered = [] + overflowed = false + firstEventAt = 0 + } + } +} diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts index de96b7b6855..24e93e2affc 100644 --- a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts @@ -1,8 +1,9 @@ +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../../../shared/filesystem-watch-batch-window' import type { FsChangeEvent } from '../../../../shared/types' - -const FILE_WATCH_FLUSH_MS = 150 -const FILE_WATCH_MAX_WAIT_MS = 500 -const MAX_FILE_WATCH_BATCH_EVENTS = 5_000 +import { MAX_BATCHED_WATCHER_EVENTS } from '../../../ipc/filesystem-watcher-event-batch' export function createFileWatchEventBatcher( worktree: string, @@ -46,7 +47,7 @@ export function createFileWatchEventBatcher( if (incomingOverflow) { events = [incomingOverflow] overflowed = true - } else if (events.length + nextEvents.length > MAX_FILE_WATCH_BATCH_EVENTS) { + } else if (events.length + nextEvents.length > MAX_BATCHED_WATCHER_EVENTS) { // Why: once precision is too expensive to retain and stream, one // overflow asks clients to refresh safely without a huge payload. events = [ @@ -66,14 +67,14 @@ export function createFileWatchEventBatcher( if (firstEventAt === 0) { firstEventAt = now } - if (now - firstEventAt >= FILE_WATCH_MAX_WAIT_MS) { + if (now - firstEventAt >= WATCH_BATCH_MAX_WAIT_MS) { flush() return } clearTimer() // Why: remote file-watch events cross the runtime WebSocket before the // renderer refreshes the tree. Match local watcher batching here. - timer = setTimeout(flush, FILE_WATCH_FLUSH_MS) + timer = setTimeout(flush, WATCH_BATCH_TRAILING_MS) if (typeof timer.unref === 'function') { timer.unref() } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index c81d2b5a6a1..1c1ff41328d 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -3316,6 +3316,195 @@ describe('PtyHandler', () => { ]) expect(handler.activePtyCount).toBe(0) }) + + describe('onPtyPoolEmpty', () => { + it('fires once when natural exit drains the last PTY, never while one remains', async () => { + const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = [] + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCallbacks.push(cb) + }) + }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + + await spawnPty() + await spawnPty() + expect(onExitCallbacks).toHaveLength(2) + + onExitCallbacks[0]({ exitCode: 0 }) + expect(handler.activePtyCount).toBe(1) + expect(poolEmpty).not.toHaveBeenCalled() + + onExitCallbacks[1]({ exitCode: 0 }) + expect(handler.activePtyCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + + it('fires when the dead-shell reap inside attach removes the last PTY', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + await spawnPty() + + const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + try { + await expect(attachPty({ id: 'pty-1', suppressReplayNotification: true })).rejects.toThrow( + 'PTY "pty-1" not found' + ) + } finally { + aliveSpy.mockRestore() + } + + expect(handler.activePtyCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + + it('fires when dispose-for-shutdown removes the last PTY', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + await spawnPty() + await spawnPty() + + await handler.dispose({ waitForPhysicalExit: false }) + + expect(handler.activePtyCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + + it('fires when a spawn fails before the PTY ever reaches the pool', async () => { + // Why: removePty can only announce a PTY it stored, so a creation that dies mid-flight + // would otherwise leave the relay believing it is still non-idle forever. + mockPtySpawn.mockImplementation(() => { + throw new Error('posix_spawnp failed') + }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + + await expect(spawnPty()).rejects.toThrow() + + expect(handler.activePtyCount).toBe(0) + expect(handler.pendingPtyCreationCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + + it('stays silent when a failing creation settles while another is still admitted', async () => { + let spawnCall = 0 + mockPtySpawn.mockImplementation(() => { + spawnCall += 1 + if (spawnCall === 1) { + throw new Error('posix_spawnp failed') + } + return { ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() } + }) + const poolEmpty = vi.fn() + handler.onPtyPoolEmpty(poolEmpty) + + // Both admissions land before either creation resolves, so the failing one must not + // announce an empty pool while the surviving one still owns a shell. + const failing = spawnPty() + const succeeding = spawnPty() + expect(handler.pendingPtyCreationCount).toBe(2) + + await expect(failing).rejects.toThrow() + await succeeding + + expect(handler.activePtyCount).toBe(1) + expect(poolEmpty).not.toHaveBeenCalled() + }) + + it('stops notifying after the returned unsubscribe runs', async () => { + const onExitCallbacks: ((evt: { exitCode: number }) => void)[] = [] + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCallbacks.push(cb) + }) + }) + const poolEmpty = vi.fn() + const unsubscribe = handler.onPtyPoolEmpty(poolEmpty) + + await spawnPty() + onExitCallbacks[0]({ exitCode: 0 }) + expect(poolEmpty).toHaveBeenCalledTimes(1) + + unsubscribe() + await spawnPty() + onExitCallbacks[1]({ exitCode: 0 }) + expect(handler.activePtyCount).toBe(0) + expect(poolEmpty).toHaveBeenCalledTimes(1) + }) + }) + + describe('onPtyPoolActive', () => { + it('fires at admission, while the pool is still empty', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolCounts: number[] = [] + handler.onPtyPoolActive(() => { + poolCounts.push(handler.activePtyCount) + }) + + const pending = spawnPty() + // The relay must learn it is non-idle here, not after the creation resolves. + expect(poolCounts).toEqual([0]) + + await pending + expect(poolCounts).toEqual([0, 1]) + }) + + it('fires for a revived PTY whose creation was admitted before the pool was empty', async () => { + await spawnPty({ cols: 80, rows: 24, cwd: '/tmp' }) + const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolActive = vi.fn() + handler.onPtyPoolActive(poolActive) + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + expect(handler.activePtyCount).toBe(1) + expect(poolActive).toHaveBeenCalled() + }) + + it('stops notifying after the returned unsubscribe runs', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + const poolActive = vi.fn() + const unsubscribe = handler.onPtyPoolActive(poolActive) + + await spawnPty() + const callsWhileSubscribed = poolActive.mock.calls.length + expect(callsWhileSubscribed).toBeGreaterThan(0) + + unsubscribe() + await spawnPty() + expect(poolActive).toHaveBeenCalledTimes(callsWhileSubscribed) + }) + }) + + it('counts a spawn admitted but not yet pooled as a pending creation', async () => { + mockPtySpawn.mockReturnValue({ ...mockPtyInstance, onData: vi.fn(), onExit: vi.fn() }) + expect(handler.pendingPtyCreationCount).toBe(0) + + const pending = spawnPty() + // The shell is already owned even though activePtyCount still reads zero. + expect(handler.activePtyCount).toBe(0) + expect(handler.pendingPtyCreationCount).toBe(1) + + await pending + expect(handler.activePtyCount).toBe(1) + expect(handler.pendingPtyCreationCount).toBe(0) + }) }) describe('attachIdentityMismatches', () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 67bfc4c6e15..cc685389f0a 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -372,6 +372,8 @@ export class PtyHandler { private reloadPtyModuleFromDisk = false // Why: single optional slot is intentional — callers compose externally; a throw is swallowed so it can't block cleanup. private exitListener: PtyExitListener | null = null + private ptyPoolEmptyListener: (() => void) | null = null + private ptyPoolActiveListener: (() => void) | null = null // Why: env augmenters run on every spawn so each PTY sees live hook coords without the dispatcher knowing about agent hooks. private envAugmenters: PtyEnvAugmenter[] = [] private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() @@ -462,6 +464,10 @@ export class PtyHandler { } } + // Why: this value never reaches the grace *timer* — startGraceTimer's only caller always passes an + // explicit timeoutMs — but relay.startGrace reads it back through configuredGraceTimeMs to pick the + // grace branch, so a host-sent change does affect shutdown behavior. Callers and the host-sleep + // consequence: docs/reference/relay-grace-time-reconfiguration.md. setGraceTimeMs(graceTimeMs: number): void { this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs)) } @@ -494,6 +500,53 @@ export class PtyHandler { this.exitListener = listener } + /** Notified when the last PTY leaves the pool, so the relay can re-arm its idle grace. */ + onPtyPoolEmpty(listener: () => void): () => void { + this.ptyPoolEmptyListener = listener + return () => { + if (this.ptyPoolEmptyListener === listener) { + this.ptyPoolEmptyListener = null + } + } + } + + /** + * Notified when a PTY creation is admitted or a PTY joins the pool. + * + * Why: the relay arms its idle cap on an empty pool, and a spawn/revive that lands after that + * decision must disarm it — creation is asynchronous, so the pool-empty edge alone can't see it. + */ + onPtyPoolActive(listener: () => void): () => void { + this.ptyPoolActiveListener = listener + return () => { + if (this.ptyPoolActiveListener === listener) { + this.ptyPoolActiveListener = null + } + } + } + + private notifyPoolListener(listener: (() => void) | null, label: string): void { + if (!listener) { + return + } + try { + listener() + } catch (err) { + process.stderr.write( + `[pty-handler] ${label} listener threw: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } + + // Why: the sole removal path, so the three exit routes can't drift on who announces an empty pool. + private removePty(id: string): void { + this.ptys.delete(id) + if (this.ptys.size > 0) { + return + } + this.notifyPoolListener(this.ptyPoolEmptyListener, 'pty-pool-empty') + } + /** Register an env augmenter merged into every spawn env *after* process.env and renderer env. * Used by the relay-hook server to inject ORCA_AGENT_HOOK_* coords. See docs/design/agent-status-over-ssh.md §3. */ addEnvAugmenter(augmenter: PtyEnvAugmenter): () => void { @@ -617,6 +670,8 @@ export class PtyHandler { private wireAndStore(managed: ManagedPty): void { managed.physicalExit = new PhysicalExitTracker() this.ptys.set(managed.id, managed) + // Why: a second announce covers any store whose admission window has already closed. + this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active') const emitIngressData = (emission: PtyIngressEmission): void => { const rawLength = emission.rawEndSeq - emission.rawStartSeq this.appendReplayBuffer(managed, emission.data) @@ -672,7 +727,7 @@ export class PtyHandler { this.publishPendingExit(managed.id) this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) - this.ptys.delete(managed.id) + this.removePty(managed.id) this.clearPtyInputState(managed.id) // Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md). disposeManagedPty(managed) @@ -1157,6 +1212,9 @@ export class PtyHandler { throw error } this.pendingSpawnCount++ + // Why: announce at admission, not at store — the relay must stop treating itself as idle before + // the creation parks on its first await, or an armed idle timer kills the shell it produces. + this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active') let finished = false return () => { if (finished) { @@ -1171,6 +1229,11 @@ export class PtyHandler { this.pendingCreationDrainResolvers.clear() } finishPtyCreationOperations(finishRemovalOperations) + // Why: a creation that failed before wireAndStore still leaves the pool empty, and removePty + // can't announce a PTY that was never stored — without this the relay never re-arms its idle cap. + if (this.ptys.size === 0 && this.pendingSpawnCount === 0) { + this.notifyPoolListener(this.ptyPoolEmptyListener, 'pty-pool-empty') + } } } @@ -1535,7 +1598,7 @@ export class PtyHandler { this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) disposeManagedPty(managed) - this.ptys.delete(id) + this.removePty(id) this.clearPtyFlowState(id) throw new Error(`PTY "${id}" not found`) } @@ -2065,7 +2128,7 @@ export class PtyHandler { this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) disposeManagedPty(managed) - this.ptys.delete(managed.id) + this.removePty(managed.id) this.clearPtyFlowState(managed.id) } } @@ -2105,6 +2168,11 @@ export class PtyHandler { return this.ptys.size } + /** Spawns admitted but not yet in the pool — each already owns a shell the relay must not treat as idle. */ + get pendingPtyCreationCount(): number { + return this.pendingSpawnCount + } + get retainedStartupCommandCount(): number { let count = 0 for (const managed of this.ptys.values()) { diff --git a/src/relay/relay-grace-branch.test.ts b/src/relay/relay-grace-branch.test.ts new file mode 100644 index 00000000000..1361715a4ea --- /dev/null +++ b/src/relay/relay-grace-branch.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { decideRelayGrace, type RelayGraceDecisionInput } from './relay-grace-branch' + +const EMPTY_DETACHED_STARTUP_GRACE_MS = 30_000 +const IDLE_RELAY_GRACE_MS = 15 * 60_000 + +function decide(overrides: Partial = {}) { + return decideRelayGrace({ + configuredGraceMs: 0, + relayIdle: false, + detached: false, + hasAcceptedSocketClient: true, + activePtyCount: 1, + retryDeferredShutdown: false, + emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS, + idleRelayGraceMs: IDLE_RELAY_GRACE_MS, + ...overrides + }) +} + +describe('decideRelayGrace', () => { + describe('the zero-only gate', () => { + it('honors a grace raised after launch instead of clamping it to the idle cap', () => { + // Why: the reported bug. startGrace used to read the launch-time argv closure, so a host that + // raised the grace to 24h via relay.configureGraceTime still got the 15-minute idle cap. + expect(decide({ configuredGraceMs: 86_400_000, relayIdle: true, activePtyCount: 0 })).toEqual( + { + branch: 'configured', + timeoutMs: 86_400_000 + } + ) + }) + + it('caps an idle relay running the unlimited default at the idle grace', () => { + // Why: prepareForHostSleep notifies graceTimeSeconds:0, which now reaches this selector. + // Deliberate behavior change: a host-sleep relay holding zero PTYs exits after the idle cap + // instead of living forever. Pinned so the revived path stays a decision, not an accident. + expect(decide({ configuredGraceMs: 0, relayIdle: true, activePtyCount: 0 })).toEqual({ + branch: 'idle-no-ptys', + timeoutMs: IDLE_RELAY_GRACE_MS + }) + }) + + it('leaves a non-idle relay on the configured branch even at the unlimited default', () => { + // Why: an admitted-but-unpooled creation keeps relayIdle false, so the cap must not apply — + // it would kill the shell that creation is about to produce (#6955). + expect(decide({ configuredGraceMs: 0, relayIdle: false })).toEqual({ + branch: 'configured', + timeoutMs: 0 + }) + }) + }) + + describe('branch precedence', () => { + it('prefers shutdown-deferred over every other branch', () => { + expect( + decide({ + retryDeferredShutdown: true, + detached: true, + hasAcceptedSocketClient: false, + relayIdle: true, + activePtyCount: 0 + }) + ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) + }) + + it('prefers startup-empty-detached over the idle cap', () => { + expect( + decide({ + detached: true, + hasAcceptedSocketClient: false, + relayIdle: true, + activePtyCount: 0 + }) + ).toEqual({ branch: 'startup-empty-detached', timeoutMs: EMPTY_DETACHED_STARTUP_GRACE_MS }) + }) + + it('bounds a configured grace on the startup-empty-detached branch', () => { + expect( + decide({ + configuredGraceMs: 5_000, + detached: true, + hasAcceptedSocketClient: false, + activePtyCount: 0 + }) + ).toEqual({ branch: 'startup-empty-detached', timeoutMs: 5_000 }) + }) + + it('does not treat a detached relay that already accepted a client as startup-empty', () => { + expect( + decide({ + detached: true, + hasAcceptedSocketClient: true, + relayIdle: true, + activePtyCount: 0 + }) + ).toEqual({ branch: 'idle-no-ptys', timeoutMs: IDLE_RELAY_GRACE_MS }) + }) + }) +}) diff --git a/src/relay/relay-grace-branch.ts b/src/relay/relay-grace-branch.ts new file mode 100644 index 00000000000..aa8aabcdd63 --- /dev/null +++ b/src/relay/relay-grace-branch.ts @@ -0,0 +1,63 @@ +/** Which rule decided the current relay grace window. */ +export type RelayGraceBranch = + | 'shutdown-deferred' + | 'startup-empty-detached' + | 'idle-no-ptys' + | 'configured' + +export type RelayGraceDecisionInput = { + /** The live configured grace, not the launch-time argv value; 0 means unlimited. */ + configuredGraceMs: number + /** Zero pooled PTYs *and* zero admitted-but-unpooled creations. */ + relayIdle: boolean + detached: boolean + hasAcceptedSocketClient: boolean + activePtyCount: number + retryDeferredShutdown: boolean + emptyDetachedStartupGraceMs: number + idleRelayGraceMs: number +} + +export type RelayGraceDecision = { + branch: RelayGraceBranch + timeoutMs: number +} + +/** + * Picks the relay's shutdown grace window. + * + * Why a separate module: relay.ts runs `main()` on import and exports nothing, so the zero-only + * gate below — the property that keeps a configured grace from being clamped to the idle cap — + * could not otherwise be tested. + */ +export function decideRelayGrace(input: RelayGraceDecisionInput): RelayGraceDecision { + // Why: a detached relay that never accepted a client has no PTY state and shouldn't linger forever. + const startupEmptyDetached = + input.detached && !input.hasAcceptedSocketClient && input.activePtyCount === 0 + // Why: zero PTYs means nothing left to preserve, so only the unlimited default is capped — an + // explicitly configured grace is honored verbatim rather than clamped down to the idle cap. + // Why: a spawn parked mid-creation is not in the pool yet, so capping on it would kill the live + // shell it is about to produce. + const idleNoPtys = input.relayIdle && input.configuredGraceMs === 0 + + const branch: RelayGraceBranch = input.retryDeferredShutdown + ? 'shutdown-deferred' + : startupEmptyDetached + ? 'startup-empty-detached' + : idleNoPtys + ? 'idle-no-ptys' + : 'configured' + + const timeoutMs = + branch === 'startup-empty-detached' + ? input.configuredGraceMs === 0 + ? input.emptyDetachedStartupGraceMs + : Math.min(input.configuredGraceMs, input.emptyDetachedStartupGraceMs) + : // Why: a refused kill leaves the PTY pooled, so the idle branch can't be reached — this + // branch must supply its own bound or the shipped grace=0 default arms no retry at all. + branch === 'idle-no-ptys' || branch === 'shutdown-deferred' + ? input.idleRelayGraceMs + : input.configuredGraceMs + + return { branch, timeoutMs } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index f282cbb483e..f807fc6ac2f 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -51,6 +51,7 @@ import { } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' +import { decideRelayGrace, type RelayGraceBranch } from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -68,6 +69,9 @@ const EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv( 'ORCA_RELAY_EMPTY_STARTUP_GRACE_MS', 60_000 ) +// Why: a relay holding zero PTYs preserves nothing, so an unlimited grace only accumulates idle daemons. +// The env override is test-only — the remote relay is launched over a non-interactive SSH exec channel that carries no client env. +const IDLE_RELAY_GRACE_MS = parseNonNegativeIntEnv('ORCA_RELAY_IDLE_GRACE_MS', 15 * 60_000) type SocketIdentity = { dev: bigint @@ -679,8 +683,21 @@ async function main(): Promise { function configureRelayGraceTime(params: Record): { graceTimeMs: number } { const seconds = Number(params.graceTimeSeconds) if (Number.isFinite(seconds) && seconds >= 0) { + const previousGraceMs = ptyHandler.configuredGraceTimeMs // Why: the host sends 0 before system sleep so live remote PTYs survive longer than the ordinary grace window. ptyHandler.setGraceTimeMs(Math.floor(seconds) * 1000) + // Why: startGrace samples the configured value at arm time, so a raise that lands while the idle + // timer is already running would still fire at the old deadline. Re-arm only on an actual change + // — the host re-asserts the same value on every establish, and restarting the window on those + // would keep a grace alive indefinitely. + if ( + ptyHandler.configuredGraceTimeMs !== previousGraceMs && + graceDeadlineAt !== null && + graceReason !== null && + !shutdownInFlight + ) { + startGrace('grace reconfigured') + } } return { graceTimeMs: ptyHandler.configuredGraceTimeMs } } @@ -829,6 +846,9 @@ async function main(): Promise { let hasAcceptedSocketClient = false let graceDeadlineAt: number | null = null let graceReason: string | null = null + // Why: only the idle branch is a "nothing left to preserve" bet, so only it may be revoked when a + // PTY appears mid-window; the other branches keep their armed deadline. + let graceBranch: RelayGraceBranch | null = null dispatcher.onRequest('relay.status', async () => ({ pid: process.pid, @@ -864,6 +884,7 @@ async function main(): Promise { } graceDeadlineAt = null graceReason = null + graceBranch = null ptyHandler.cancelGraceTimer() } @@ -1116,26 +1137,45 @@ async function main(): Promise { dispatcher.invalidateClient() }) - function startGrace(reason: string): void { - const startupEmptyDetached = - detached && !hasAcceptedSocketClient && ptyHandler.activePtyCount === 0 - // Why: a detached relay that never accepted a client has no PTY state and shouldn't linger forever. - const timeoutMs = startupEmptyDetached - ? graceTimeMs === 0 - ? EMPTY_DETACHED_STARTUP_GRACE_MS - : Math.min(graceTimeMs, EMPTY_DETACHED_STARTUP_GRACE_MS) - : graceTimeMs + function startGrace(reason: string, options?: { retryDeferredShutdown?: boolean }): void { + // Why: the live configured value, not the launch-time argv closure — the host can raise the grace + // after launch via relay.configureGraceTime, and a zero-only gate reading a stale zero would be + // zero-at-launch-only, i.e. correct only by coincidence. + const decision = decideRelayGrace({ + configuredGraceMs: ptyHandler.configuredGraceTimeMs, + relayIdle: isRelayIdle(), + detached, + hasAcceptedSocketClient, + activePtyCount: ptyHandler.activePtyCount, + retryDeferredShutdown: options?.retryDeferredShutdown === true, + emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS, + idleRelayGraceMs: IDLE_RELAY_GRACE_MS + }) + graceBranch = decision.branch + const timeoutMs = decision.timeoutMs graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs graceReason = reason relayLogLine( - `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` + `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, branch=${graceBranch}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` ) ptyHandler.startGraceTimer(() => { + // Why: last line of defense for the idle cap — a PTY that appeared without announcing itself + // must not be killed by a timer armed while the relay was still empty (#6955). + if (graceBranch === 'idle-no-ptys' && !isRelayIdle()) { + relayLogLine(`[relay] Grace expired (${reason}) but relay is no longer idle; re-evaluating`) + startGrace(reason) + return + } relayLogLine(`[relay] Grace expired (${reason}); shutting down`) shutdown() }, timeoutMs) } + // Why: a creation admitted but not yet pooled already owns a shell, so it counts as non-idle. + function isRelayIdle(): boolean { + return ptyHandler.activePtyCount === 0 && ptyHandler.pendingPtyCreationCount === 0 + } + if (detached) { // Why: detached stdin is /dev/null, so listening would EOF → grace → shutdown before --connect arrives; use the socket instead. startGrace('detached startup') @@ -1176,9 +1216,12 @@ async function main(): Promise { ) graceDeadlineAt = null graceReason = null + graceBranch = null void ptyHandler .dispose() .then(() => { + stopPoolWatch() + stopPoolActiveWatch() dispatcher.dispose() fsHandler.dispose() gitHandler.dispose() @@ -1192,13 +1235,41 @@ async function main(): Promise { }) .catch((error) => { // Why: keep owning a PTY whose native kill was rejected so a transient signal failure doesn't orphan a remote shell. + // Why: the pool watches stay registered — the socket server is still listening, so a client can + // reconnect and cancel this grace, and that revived relay still needs both re-evaluations. shutdownInFlight = false relayLogLine( `[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}` ) + // Why: shutdown() already cleared graceReason, so without re-arming a client-less relay + // whose kill was refused would stay resident forever with nothing left to retry it. + if (socketClients.size === 0) { + startGrace('shutdown deferred', { retryDeferredShutdown: true }) + } }) } + // Why: with the shipped unlimited default the grace timer is never armed, so an expiry can't + // notice the pool emptying; re-evaluate on the last exit instead. graceReason (not + // graceTimerActive) is the only signal that a grace window is open in that configuration. + // Why: a null deadline is exactly that unlimited case; re-arming a scheduled one would restart + // an explicitly configured window and double it. + const stopPoolWatch = ptyHandler.onPtyPoolEmpty(() => { + if (graceReason !== null && graceDeadlineAt === null && !shutdownInFlight) { + startGrace('last pty exited') + } + }) + + // Why: the idle cap is armed on an empty pool, but creation is asynchronous — a spawn or revive + // admitted after that decision (the client dropped mid-`pty.revive`, say) makes the relay non-idle + // again, and re-evaluating disarms the timer instead of letting it kill a live PTY (#6955). + // The grace window itself stays open, so the PTY's own exit still re-arms the cap. + const stopPoolActiveWatch = ptyHandler.onPtyPoolActive(() => { + if (graceBranch === 'idle-no-ptys' && graceReason !== null && !shutdownInFlight) { + startGrace(graceReason) + } + }) + process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) // Why: default SIGHUP exits immediately, killing PTYs before grace; ignore it so the relay survives SSH disconnect. diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index e80a3ccec86..57b3466707d 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -108,6 +108,57 @@ const WORKING_NODE_PTY_MODULE = `module.exports = { spawn() { return { onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, clear() {} } } }\n` +// A shell that reports its own exit after a delay, so the relay sees the pool drain on its own. +function selfExitingNodePtyModule(exitAfterMs: number): string { + return `module.exports = { spawn() { + const exitHandlers = [] + setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, ${exitAfterMs}) + return { + pid: process.pid, + process: 'mock-shell', + onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, kill() {}, clear() {} + } +} }\n` +} + +// A shell whose first dispose is refused, so ptyHandler.dispose() rejects once before a retry can succeed. +const KILL_REJECTS_FIRST_DISPOSE_MODULE = `let killAttempts = 0 +module.exports = { spawn() { + const exitHandlers = [] + return { + pid: 2147483646, + process: 'mock-shell', + onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, clear() {}, + kill() { + killAttempts++ + if (killAttempts <= 2) { throw new Error('kill refused') } + setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, 0) + } + } +} }\n` + +// Why: an ESM mock with top-level await parks loadPty() in the window where a spawn is admitted but not yet pooled. +function writeSlowLoadingNodePty(root: string, loadDelayMs: number): void { + const nodePtyDir = path.join(root, 'node_modules', 'node-pty') + mkdirSync(path.join(nodePtyDir, 'lib'), { recursive: true }) + writeFileSync(path.join(nodePtyDir, 'package.json'), '{"type":"module","main":"lib/index.js"}\n') + writeFileSync( + path.join(nodePtyDir, 'lib', 'index.js'), + `await new Promise((resolve) => setTimeout(resolve, ${loadDelayMs})) +export function spawn() { + const exitHandlers = [] + return { + pid: process.pid, + process: 'mock-shell', + onData() {}, onExit(cb) { exitHandlers.push(cb) }, write() {}, resize() {}, clear() {}, + // Report the exit so relay shutdown can complete instead of parking on waitForPhysicalExit. + kill() { setTimeout(() => { for (const cb of exitHandlers) { cb({ exitCode: 0, signal: 0 }) } }, 0) } + } +} +` + ) +} + describe('Subprocess: Relay entry point', () => { let relay: RelayProcess | null = null let tmpDir: string @@ -504,6 +555,210 @@ describe('Subprocess: Relay entry point', () => { 10_000 ) + // Why: a relay holding zero PTYs preserves nothing, so the unlimited default must still be + // bounded once a client has come and gone — but an explicitly configured grace is never shortened. + function spawnIdleGraceDaemon( + nodePtyModule: string, + graceTimeSeconds: string, + idleGraceMs: string + ): { daemon: RelayProcess; sockPath: string } { + const daemonEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, daemonEntry) + writeMockNodePty(tmpDir, nodePtyModule) + const sockPath = path.join(tmpDir, 'relay.sock') + const daemon = spawnRelayEntry( + daemonEntry, + ['--detached', '--grace-time', graceTimeSeconds, '--sock-path', sockPath], + { ...process.env, ORCA_RELAY_IDLE_GRACE_MS: idleGraceMs } + ) + return { daemon, sockPath } + } + + async function connectAndDisconnect( + sockPath: string, + whileConnected?: (bridge: RelayProcess) => Promise + ): Promise { + const bridge = spawn(['--connect', '--sock-path', sockPath]) + try { + await bridge.sentinelReceived + await whileConnected?.(bridge) + } finally { + bridge.kill('SIGTERM') + await bridge.waitForExit().catch(() => {}) + } + } + + it.skipIf(process.platform === 'win32')( + 'shuts down an idle relay with no PTYs after the idle grace even with --grace-time 0', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-')) + const sockPath = path.join(tmpDir, 'relay.sock') + relay = spawn(['--detached', '--grace-time', '0', '--sock-path', sockPath], { + ...process.env, + ORCA_RELAY_IDLE_GRACE_MS: '200' + }) + await relay.sentinelReceived + + // Accepting a client defeats the startup-empty branch, so only the idle cap can end this relay. + await connectAndDisconnect(sockPath) + + await relay.waitForExit(2000) + expect(relay.proc.exitCode).toBe(0) + }, + 15_000 + ) + + it.skipIf(process.platform === 'win32')( + 'keeps a relay with a live PTY alive past the idle grace', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-live-pty-')) + const { daemon, sockPath } = spawnIdleGraceDaemon(WORKING_NODE_PTY_MODULE, '0', '200') + relay = daemon + await relay.sentinelReceived + + await connectAndDisconnect(sockPath, async (bridge) => { + const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) + expect(resp.error).toBeUndefined() + }) + + await new Promise((resolve) => setTimeout(resolve, 1200)) + expect(relay.proc.exitCode).toBeNull() + }, + 15_000 + ) + + it.skipIf(process.platform === 'win32')( + 're-arms the idle grace when the last PTY exits during grace', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-rearm-')) + const { daemon, sockPath } = spawnIdleGraceDaemon(selfExitingNodePtyModule(1500), '0', '200') + relay = daemon + await relay.sentinelReceived + + await connectAndDisconnect(sockPath, async (bridge) => { + const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) + expect(resp.error).toBeUndefined() + }) + + // The live PTY must suppress the idle grace at disconnect; only its own exit re-arms it. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(relay.proc.exitCode).toBeNull() + + await relay.waitForExit(5000) + expect(relay.proc.exitCode).toBe(0) + }, + 20_000 + ) + + it.skipIf(process.platform === 'win32')( + 'honors an explicitly configured grace instead of clamping it to the idle cap', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-configured-')) + const sockPath = path.join(tmpDir, 'relay.sock') + relay = spawn(['--detached', '--grace-time', '3600', '--sock-path', sockPath], { + ...process.env, + ORCA_RELAY_IDLE_GRACE_MS: '200' + }) + await relay.sentinelReceived + + await connectAndDisconnect(sockPath) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + expect(relay.proc.exitCode).toBeNull() + }, + 15_000 + ) + + it.skipIf(process.platform === 'win32')( + 'keeps the relay alive when the client drops while a PTY creation is still in flight', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-idle-inflight-')) + const daemonEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, daemonEntry) + writeSlowLoadingNodePty(tmpDir, 1500) + const sockPath = path.join(tmpDir, 'relay.sock') + relay = spawnRelayEntry( + daemonEntry, + ['--detached', '--grace-time', '0', '--sock-path', sockPath], + { ...process.env, ORCA_RELAY_IDLE_GRACE_MS: '200' } + ) + await relay.sentinelReceived + + const bridge = spawn(['--connect', '--sock-path', sockPath]) + await bridge.sentinelReceived + // Revive, not spawn: it has no client-disconnect abort, so it really does produce a live shell + // after the parked module load resolves. + bridge.send('pty.revive', { + state: JSON.stringify([ + { id: 'pty-restored', pid: process.pid, cols: 80, rows: 24, cwd: tmpDir } + ]) + }) + // Let the creation park on the module load: it already owns a shell but is not in the pool yet. + await new Promise((resolve) => setTimeout(resolve, 400)) + bridge.kill('SIGTERM') + await bridge.waitForExit().catch(() => {}) + + // Well past the idle cap and past the point where the parked creation lands in the pool. + await new Promise((resolve) => setTimeout(resolve, 2500)) + expect(relay.proc.exitCode).toBeNull() + + const probe = spawn(['--connect', '--sock-path', sockPath]) + try { + await probe.sentinelReceived + const status = await probe.waitForResponse(probe.send('relay.status')) + expect((status.result as { ptys: { active: number } }).ptys.active).toBe(1) + } finally { + probe.kill('SIGTERM') + await probe.waitForExit().catch(() => {}) + } + }, + 20_000 + ) + + it.skipIf(process.platform === 'win32')( + 'does not extend an explicitly configured grace when the last PTY exits mid-window', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-configured-rearm-')) + const { daemon, sockPath } = spawnIdleGraceDaemon(selfExitingNodePtyModule(2500), '3', '200') + relay = daemon + await relay.sentinelReceived + + await connectAndDisconnect(sockPath, async (bridge) => { + const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) + expect(resp.error).toBeUndefined() + }) + + // The 3s window keeps governing; re-arming at the ~2.5s PTY exit would push shutdown past 5s. + await relay.waitForExit(4200) + expect(relay.proc.exitCode).toBe(0) + }, + 20_000 + ) + + it.skipIf(process.platform === 'win32')( + 're-arms grace after a shutdown deferred by a rejected kill, so the relay still exits', + async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-shutdown-deferred-')) + const { daemon, sockPath } = spawnIdleGraceDaemon( + KILL_REJECTS_FIRST_DISPOSE_MODULE, + '2', + '200' + ) + relay = daemon + await relay.sentinelReceived + + await connectAndDisconnect(sockPath, async (bridge) => { + const resp = await bridge.waitForResponse(bridge.send('pty.spawn', { cols: 80, rows: 24 })) + expect(resp.error).toBeUndefined() + }) + + // First grace expiry hits the refused kill; only the re-armed window can retry it. + await relay.waitForExit(8000) + expect(relay.proc.exitCode).toBe(0) + }, + 20_000 + ) + it('reports relay diagnostics over relay.status', async () => { relay = spawn() await relay.sentinelReceived diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 81a41e20aea..9e22f01ac5d 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -42,6 +42,7 @@ import { useFileExplorerDragDrop } from './useFileExplorerDragDrop' import { useFileExplorerImport } from './useFileExplorerImport' import { useFileExplorerManualRefresh } from './useFileExplorerManualRefresh' import { useFileExplorerTree } from './useFileExplorerTree' +import { decideExpandedDirLoad } from './file-explorer-stale-dir-cache' import { useFileExplorerWatch } from './useFileExplorerWatch' import { buildAddProjectFromFolderModalData, @@ -148,6 +149,7 @@ function FileExplorerFiles(): React.JSX.Element { markPathAsDirectory, refreshTree, refreshDir, + isDirStale, resetAndLoad } = useFileExplorerTree(worktreePath, expanded, activeWorktreeId) const hasNameFilterQuery = nameFilterQuery.trim().length > 0 @@ -347,11 +349,14 @@ function FileExplorerFiles(): React.JSX.Element { return } for (const dirPath of expanded) { - if (!dirCache[dirPath]?.children.length && !dirCache[dirPath]?.loading) { - const depth = - splitPathSegments(dirPath.slice(visibleFilesWorktreePath.length + 1)).length - 1 - void loadDir(dirPath, depth) + // Why: a full refresh (watcher overflow) re-reads only root and the dirs expanded at the time, + // so a listing cached while collapsed is unverified — re-read it here instead of trusting it. + const decision = decideExpandedDirLoad(dirCache[dirPath], isDirStale(dirPath)) + if (decision === 'skip') { + continue } + const depth = splitPathSegments(dirPath.slice(visibleFilesWorktreePath.length + 1)).length - 1 + void loadDir(dirPath, depth, decision === 'reload' ? { force: true } : undefined) } }, [expanded, visibleFilesWorktreePath]) // eslint-disable-line react-hooks/exhaustive-deps diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts similarity index 58% rename from src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts rename to src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts index b98e61c825e..97e2dbd25d3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts @@ -3,7 +3,7 @@ import type { SetStateAction } from 'react' import type { DirEntry } from '../../../../shared/types' import type { DirCache } from './file-explorer-types' import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' -import { refreshFileExplorerExpandedDirs } from './useFileExplorerTree' +import { refreshFileExplorerExpandedDirs } from './file-explorer-expanded-dirs-refresh' type CacheUpdate = SetStateAction> @@ -44,7 +44,10 @@ describe('refreshFileExplorerExpandedDirs', () => { worktreePath: '/repo', dirLoadTracker: createFileExplorerDirLoadTracker(), setDirCache, - readDirectory + readDirectory, + // A limit at or above the dir count must stay a single wave, i.e. exactly + // today's two batched writes. + maxConcurrentReads: 16 }) expect(refreshed).toBe(true) @@ -128,7 +131,8 @@ describe('refreshFileExplorerExpandedDirs', () => { worktreePath: '/repo', dirLoadTracker: tracker, setDirCache, - readDirectory + readDirectory, + maxConcurrentReads: 16 }) // Not every dir was still current, so the refresh reports partial completion. @@ -172,7 +176,8 @@ describe('refreshFileExplorerExpandedDirs', () => { worktreePath: '/repo', dirLoadTracker: tracker, setDirCache, - readDirectory + readDirectory, + maxConcurrentReads: 16 }) // Let /repo/src resolve (and pass its resolve-time token check) while @@ -207,4 +212,137 @@ describe('refreshFileExplorerExpandedDirs', () => { children: [{ name: 'guide.md' }] }) }) + + it('never exceeds maxConcurrentReads in flight and still commits every directory', async () => { + const dirs = Array.from({ length: 20 }, (_, index) => ({ + dirPath: `/repo/d${index}`, + depth: 0 + })) + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + let inFlight = 0 + let peakInFlight = 0 + const readDirectory = vi.fn(async (dirPath: string) => { + inFlight++ + peakInFlight = Math.max(peakInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 0)) + inFlight-- + return { + entries: [entry(`${dirPath.slice('/repo/'.length)}.ts`)], + operationOwner: { kind: 'local' as const } + } + }) + + const refreshed = await refreshFileExplorerExpandedDirs({ + dirs, + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory, + maxConcurrentReads: 4 + }) + + expect(refreshed).toBe(true) + expect(peakInFlight).toBeLessThanOrEqual(4) + expect(readDirectory).toHaveBeenCalledTimes(20) + // One up-front loading write plus one result write per wave (five waves). + expect(setDirCache).toHaveBeenCalledTimes(6) + for (const { dirPath } of dirs) { + expect(cache[dirPath]).toMatchObject({ + loading: false, + children: [{ name: expect.any(String) }] + }) + } + }) + + it('marks queued dirs loading up front so only the first wave reads', async () => { + const dirs = Array.from({ length: 9 }, (_, index) => ({ + dirPath: `/repo/d${index}`, + depth: 0 + })) + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + let releaseFirstWave!: () => void + const firstWaveGate = new Promise((resolve) => { + releaseFirstWave = resolve + }) + const readDirectory = vi.fn(async (dirPath: string) => { + if (['/repo/d0', '/repo/d1', '/repo/d2'].includes(dirPath)) { + await firstWaveGate + } + return { entries: [], operationOwner: { kind: 'local' as const } } + }) + + const refreshPromise = refreshFileExplorerExpandedDirs({ + dirs, + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory, + maxConcurrentReads: 3 + }) + await Promise.resolve() + + expect(cache['/repo/d0']).toMatchObject({ loading: true }) + // A queued dir must already advertise loading:true, or FileExplorer's + // auto-load effect fans out an unbounded loadDir for it on the next + // `expanded` change — the reads this cap exists to bound. + expect(cache['/repo/d6']).toMatchObject({ loading: true }) + expect(readDirectory).toHaveBeenCalledTimes(3) + + releaseFirstWave() + await refreshPromise + + expect(cache['/repo/d6']).toMatchObject({ loading: false }) + }) + + it('drops a later-wave directory superseded while an earlier wave was reading', async () => { + const tracker = createFileExplorerDirLoadTracker() + let cache: Record = {} + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const readDirectory = vi.fn(async (dirPath: string) => { + if (dirPath === '/repo/a') { + await firstGate + } + return { entries: [entry('x.ts')], operationOwner: { kind: 'local' as const } } + }) + + const refreshPromise = refreshFileExplorerExpandedDirs({ + dirs: [ + { dirPath: '/repo/a', depth: 0 }, + { dirPath: '/repo/b', depth: 0 } + ], + worktreePath: '/repo', + dirLoadTracker: tracker, + setDirCache, + readDirectory, + maxConcurrentReads: 1 + }) + await Promise.resolve() + + // A watcher-driven refreshDir supersedes the queued wave-2 dir before it + // ever starts reading; its token was already taken up front. + tracker.begin('/repo/b') + const newerBCache: DirCache = { loading: false, children: [] } + setDirCache((prev) => ({ ...prev, '/repo/b': newerBCache })) + + releaseFirst() + const refreshed = await refreshPromise + + expect(refreshed).toBe(false) + expect(cache['/repo/a']).toMatchObject({ loading: false, children: [{ name: 'x.ts' }] }) + // Wave 2 must neither read nor commit the superseded dir. + expect(readDirectory).toHaveBeenCalledTimes(1) + expect(cache['/repo/b']).toEqual(newerBCache) + }) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.ts b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.ts new file mode 100644 index 00000000000..280520c6425 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.ts @@ -0,0 +1,137 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { DirCache } from './file-explorer-types' +import type { FileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' +import { + fileExplorerEntriesToTreeNodes, + type FileExplorerDirectoryListing +} from './file-explorer-directory-listing' + +export type RefreshFileExplorerTreeDir = { + dirPath: string + depth: number +} + +export type RefreshFileExplorerExpandedDirsParams = { + dirs: RefreshFileExplorerTreeDir[] + worktreePath: string + dirLoadTracker: FileExplorerDirLoadTracker + setDirCache: Dispatch>> + readDirectory: (dirPath: string) => Promise + maxConcurrentReads: number + /** Called per dir whose fresh listing was committed, so callers can clear a staleness mark. */ + onDirCommitted?: (dirPath: string) => void +} + +export async function refreshFileExplorerExpandedDirs({ + dirs, + worktreePath, + dirLoadTracker, + setDirCache, + readDirectory, + maxConcurrentReads, + onDirCommitted +}: RefreshFileExplorerExpandedDirsParams): Promise { + if (dirs.length === 0) { + return true + } + + const uniqueDirs = Array.from(new Map(dirs.map((dir) => [dir.dirPath, dir])).values()) + // Why: begin every token before the first read so a concurrent refreshDir or + // worktree reset supersedes dirs still queued in a later wave, exactly as the + // single-batch version did. + const loadTokens = new Map( + uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)]) + ) + const waveSize = Math.max(1, Math.floor(maxConcurrentReads)) + let committedDirs = 0 + + // Why: mark every dir loading up front, not per wave — FileExplorer's auto-load + // effect re-runs on any `expanded` change and fans out an unbounded loadDir per + // dir that is neither cached nor loading, which would defeat the wave cap. + setDirCache((prev) => { + const next = { ...prev } + for (const { dirPath } of uniqueDirs) { + next[dirPath] = { + children: prev[dirPath]?.children ?? [], + loading: true + } + } + return next + }) + + for (let start = 0; start < uniqueDirs.length; start += waveSize) { + // Why: a dir superseded while an earlier wave was reading is left to its + // newer load — reading it here would only burn a round trip for a result + // that is dropped at commit time. + const wave = uniqueDirs + .slice(start, start + waveSize) + .filter(({ dirPath }) => dirLoadTracker.isCurrent(loadTokens.get(dirPath)!)) + if (wave.length === 0) { + continue + } + + // Why: batch each wave's results into one setDirCache write, so the whole + // refresh costs 1 + ceil(N / waveSize) cache spreads rather than one per dir. + const results = await Promise.all( + wave.map(async ({ dirPath, depth }) => { + const loadToken = loadTokens.get(dirPath)! + try { + const listing = await readDirectory(dirPath) + if (!dirLoadTracker.isCurrent(loadToken)) { + return { current: false as const } + } + return { + current: true as const, + dirPath, + cache: { + children: fileExplorerEntriesToTreeNodes( + listing.entries, + dirPath, + depth, + worktreePath, + listing.operationOwner + ), + loading: false, + operationOwner: listing.operationOwner + } + } + } catch { + if (!dirLoadTracker.isCurrent(loadToken)) { + return { current: false as const } + } + return { + current: true as const, + dirPath, + cache: { children: [], loading: false } + } + } + }) + ) + + // Why: the batch commits only after the slowest read in the wave, so a dir + // can be superseded (watcher refreshDir, worktree reset) after its own read + // resolved. Re-check tokens at commit time so the batched write never + // clobbers a newer load — preserving the old per-dir commit ordering. + const currentResults = results.filter( + (result): result is Extract => + result.current && dirLoadTracker.isCurrent(loadTokens.get(result.dirPath)!) + ) + if (currentResults.length === 0) { + continue + } + + setDirCache((prev) => { + const next = { ...prev } + for (const result of currentResults) { + next[result.dirPath] = result.cache + } + return next + }) + for (const result of currentResults) { + onDirCommitted?.(result.dirPath) + } + committedDirs += currentResults.length + } + + return committedDirs === uniqueDirs.length +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts new file mode 100644 index 00000000000..b1d319933b7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency' + +describe('fileExplorerRefreshConcurrency', () => { + it('allows the widest fan-out for local disk reads', () => { + expect(fileExplorerRefreshConcurrency({ kind: 'local' })).toBe(16) + }) + + it('caps SSH-owned worktrees at the remote tier', () => { + expect(fileExplorerRefreshConcurrency({ kind: 'ssh', connectionId: 'target-1' })).toBe(4) + }) + + it('treats an SSH-backed runtime as remote', () => { + expect( + fileExplorerRefreshConcurrency({ + kind: 'runtime', + environmentId: 'env-1', + executionHostId: 'ssh:target-1' + }) + ).toBe(4) + }) + + it('uses the runtime tier for a non-SSH runtime host', () => { + expect( + fileExplorerRefreshConcurrency({ + kind: 'runtime', + environmentId: 'env-1', + executionHostId: 'runtime:env-1' + }) + ).toBe(8) + }) + + it('falls back to the conservative remote tier when ownership is unresolved', () => { + expect(fileExplorerRefreshConcurrency({ kind: 'unresolved' })).toBe(4) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.ts b/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.ts new file mode 100644 index 00000000000..49c6170ad6b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.ts @@ -0,0 +1,31 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { FileExplorerOperationOwner } from './file-explorer-types' + +export const FILE_EXPLORER_LOCAL_REFRESH_CONCURRENCY = 16 +export const FILE_EXPLORER_RUNTIME_REFRESH_CONCURRENCY = 8 +export const FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY = 4 + +/** + * In-flight directory-read cap for one File Explorer refresh, by how expensive a + * read is on the owning transport. + * + * Why: tiers come from the owner, not from `getFileExplorerOperationRoute`, + * which maps every non-SSH runtime host to `expectedExecutionHostId: 'local'` — + * including an `orca serve` runtime reached over a network socket, whose reads + * are RPC round trips rather than disk reads. + */ +export function fileExplorerRefreshConcurrency(owner: FileExplorerOperationOwner): number { + switch (owner.kind) { + case 'local': + return FILE_EXPLORER_LOCAL_REFRESH_CONCURRENCY + case 'ssh': + return FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY + case 'runtime': + return parseExecutionHostId(owner.executionHostId)?.kind === 'ssh' + ? FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY + : FILE_EXPLORER_RUNTIME_REFRESH_CONCURRENCY + case 'unresolved': + // Why: unknown ownership could be the slowest transport; stay conservative. + return FILE_EXPLORER_REMOTE_REFRESH_CONCURRENCY + } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts new file mode 100644 index 00000000000..e0cf1c1eccf --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { DirCache, TreeNode } from './file-explorer-types' +import { collectStaleDirCachePaths, decideExpandedDirLoad } from './file-explorer-stale-dir-cache' + +function cache(...dirPaths: string[]): Record { + return Object.fromEntries(dirPaths.map((dirPath) => [dirPath, { children: [], loading: false }])) +} + +describe('collectStaleDirCachePaths', () => { + it('marks a cached dir that a full refresh will not re-read', () => { + expect( + collectStaleDirCachePaths(cache('/repo', '/repo/src', '/repo/docs'), '/repo', new Set()) + ).toEqual(['/repo/src', '/repo/docs']) + }) + + it('never marks the root or a currently expanded dir', () => { + expect( + collectStaleDirCachePaths( + cache('/repo', '/repo/src', '/repo/docs'), + '/repo', + new Set(['/repo/src']) + ) + ).toEqual(['/repo/docs']) + }) + + it('returns nothing when the cache holds only refreshed dirs', () => { + expect( + collectStaleDirCachePaths(cache('/repo', '/repo/src'), '/repo', new Set(['/repo/src'])) + ).toEqual([]) + }) +}) + +describe('decideExpandedDirLoad', () => { + const children = [{ name: 'gone.ts', path: '/repo/src/gone.ts' } as TreeNode] + + it('re-reads a cached dir whose listing the last full refresh skipped', () => { + expect(decideExpandedDirLoad({ children, loading: false }, true)).toBe('reload') + }) + + it('trusts a cached listing that is not stale', () => { + expect(decideExpandedDirLoad({ children, loading: false }, false)).toBe('skip') + }) + + it('reads a dir that has never been listed', () => { + expect(decideExpandedDirLoad(undefined, false)).toBe('load') + expect(decideExpandedDirLoad({ children: [], loading: false }, false)).toBe('load') + }) + + it('never stacks a read on one already in flight, stale or not', () => { + expect(decideExpandedDirLoad({ children, loading: true }, true)).toBe('skip') + expect(decideExpandedDirLoad({ children: [], loading: true }, false)).toBe('skip') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.ts b/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.ts new file mode 100644 index 00000000000..007e18960c8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.ts @@ -0,0 +1,34 @@ +import type { DirCache } from './file-explorer-types' + +/** + * Cached directories a full tree refresh does not re-read. + * + * Why: `refreshTree` reads the root plus the currently expanded dirs, but `toggleDir` drops a dir + * from `expandedDirs` without purging `dirCache`. After a watcher overflow discarded the precise + * events, those collapsed-but-cached listings are unverified and must be re-read on re-expansion — + * otherwise a file deleted while the dir was collapsed stays visible forever, since the expansion + * effect skips any dir that already has children. + */ +export function collectStaleDirCachePaths( + cache: Record, + worktreePath: string, + expanded: Set +): string[] { + return Object.keys(cache).filter((dirPath) => dirPath !== worktreePath && !expanded.has(dirPath)) +} + +export type ExpandedDirLoadDecision = 'skip' | 'load' | 'reload' + +/** What the expansion effect owes a newly expanded dir: nothing, a first read, or a forced re-read. */ +export function decideExpandedDirLoad( + cached: DirCache | undefined, + stale: boolean +): ExpandedDirLoadDecision { + if (cached?.loading) { + return 'skip' + } + if (!cached?.children.length) { + return 'load' + } + return stale ? 'reload' : 'skip' +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-types.ts b/src/renderer/src/components/right-sidebar/file-explorer-types.ts index 7cf4bbb96d3..8fa8a432544 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-types.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-types.ts @@ -22,3 +22,12 @@ export type DirCache = { loading: boolean operationOwner?: FileExplorerOperationOwner } + +/** + * How a full tree refresh ended. + * + * Why three states, not a boolean: a caller that dropped its own pending dir refreshes because the + * tree refresh covered them must re-issue on `superseded` (someone newer owns those reads) but NOT + * on `root-unreadable` — there the transport is down, and re-issuing buys one dead timeout per dir. + */ +export type FileExplorerTreeRefreshOutcome = 'refreshed' | 'superseded' | 'root-unreadable' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts index d62420f7915..c6d221b4fad 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts @@ -439,3 +439,32 @@ describe('processFileExplorerFsPayload update reconciliation', () => { expect(remainingExpanded).toEqual(new Set()) }) }) + +describe('processFileExplorerFsPayload overflow reconciliation', () => { + it('routes an overflow event to a single tree refresh and skips per-dir refreshes', () => { + const root = '/repo' + const refreshDir = vi.fn() + const refreshTree = vi.fn() + processFileExplorerFsPayload({ + payload: { + worktreePath: root, + events: [ + { kind: 'create', absolutePath: `${root}/a.txt` }, + { kind: 'overflow', absolutePath: root }, + { kind: 'create', absolutePath: `${root}/src/b.txt` } + ] + }, + currentWorktreePath: root, + worktreeId: 'wt-1', + cache: { [root]: cacheWithChildren([]), [`${root}/src`]: cacheWithChildren([]) }, + expanded: new Set([`${root}/src`]), + setDirCache: vi.fn(), + setSelectedPath: vi.fn(), + refreshDir, + refreshTree + }) + + expect(refreshTree).toHaveBeenCalledOnce() + expect(refreshDir).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts new file mode 100644 index 00000000000..376479eb388 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts @@ -0,0 +1,382 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createFileExplorerWatchRefreshScheduler } from './file-explorer-watch-refresh-scheduler' +import type { FileExplorerTreeRefreshOutcome } from './file-explorer-types' + +const TRAILING_MS = 150 +const MAX_WAIT_MS = 500 + +function createDeferred(): { + promise: Promise + resolve: (outcome?: FileExplorerTreeRefreshOutcome) => void +} { + let resolve!: (outcome?: FileExplorerTreeRefreshOutcome) => void + const promise = new Promise((res) => { + resolve = (outcome = 'refreshed') => res(outcome) + }) + return { promise, resolve } +} + +function setup( + overrides: Partial[0]> = {} +) { + const { refreshTree: treeImpl, refreshDir: dirImpl, ...rest } = overrides + const refreshTree = vi.fn( + treeImpl ?? (async (): Promise => 'refreshed') + ) + const refreshDir = vi.fn(dirImpl ?? (async (_dirPath: string) => {})) + const scheduler = createFileExplorerWatchRefreshScheduler({ + refreshTree, + refreshDir, + isCoveredByFullRefresh: () => false, + dirConcurrency: 4, + ...rest + }) + return { refreshTree, refreshDir, scheduler } +} + +describe('createFileExplorerWatchRefreshScheduler', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces a burst of full-refresh requests into one tree refresh', async () => { + const { refreshTree, scheduler } = setup() + + for (let index = 0; index < 20; index++) { + scheduler.requestFullRefresh() + } + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).toHaveBeenCalledTimes(1) + }) + + it('flushes at the max wait even when requests keep arriving inside the trailing window', async () => { + const { refreshTree, scheduler } = setup() + + scheduler.requestFullRefresh() + for (let index = 0; index < 4; index++) { + await vi.advanceTimersByTimeAsync(100) + scheduler.requestFullRefresh() + } + expect(refreshTree).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS - 400) + + expect(refreshTree).toHaveBeenCalledTimes(1) + }) + + it('dedupes dir refreshes that differ only in path spelling', async () => { + const { refreshDir, scheduler } = setup() + + // Spellings only the runtime-path normalizer folds: Windows separators and a + // trailing slash. Raw-string keying would issue three reads of one dir. + scheduler.requestDirRefresh('C:/repo/src/') + scheduler.requestDirRefresh('C:\\repo\\src') + scheduler.requestDirRefresh('C:/repo/src') + scheduler.requestDirRefresh('/repo/docs/') + scheduler.requestDirRefresh('/repo/docs') + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshDir).toHaveBeenCalledTimes(2) + expect(refreshDir.mock.calls.map(([dirPath]) => dirPath)).toEqual(['C:/repo/src', '/repo/docs']) + }) + + it('still refreshes a pending dir that a full refresh does not cover', async () => { + // Regression guard: dirCache keys are a strict superset of expanded dirs, so + // a full refresh must not subsume pending dir refreshes. + const { refreshTree, refreshDir, scheduler } = setup() + + scheduler.requestDirRefresh('/repo/collapsed') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/collapsed') + }) + + it('skips a pending dir the full refresh already re-read', async () => { + const { refreshTree, refreshDir, scheduler } = setup({ + isCoveredByFullRefresh: (dirPath) => dirPath === '/repo/src' + }) + + scheduler.requestDirRefresh('/repo/src') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).toHaveBeenCalledTimes(1) + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('re-issues covered dir refreshes when the tree refresh was superseded', async () => { + // Regression guard: refreshTree bails without re-reading the expanded dirs when its root + // load is superseded. Nothing marks an expanded dir stale, so dropping these would leave + // them stale until a manual refresh — collapse/re-expand does not heal it. + const gate = createDeferred() + const { refreshDir, scheduler } = setup({ + refreshTree: () => gate.promise, + isCoveredByFullRefresh: (dirPath) => dirPath === '/repo/src' + }) + + scheduler.requestDirRefresh('/repo/src') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + gate.resolve('superseded') + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/src') + }) + + it('drops pending dir refreshes when the tree refresh could not read the root', async () => { + // Regression guard: a failed root read means the transport is down, so re-issuing buys one + // dead timeout per dir and holds inFlight open, blocking every later refresh. + const gate = createDeferred() + const { refreshDir, scheduler } = setup({ + refreshTree: () => gate.promise, + isCoveredByFullRefresh: () => false + }) + + scheduler.requestDirRefresh('/repo/src') + scheduler.requestDirRefresh('/repo/docs') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + gate.resolve('root-unreadable') + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('refreshes a covered dir when no full refresh is pending', async () => { + const { refreshTree, refreshDir, scheduler } = setup({ + isCoveredByFullRefresh: () => true + }) + + scheduler.requestDirRefresh('/repo/src') + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).not.toHaveBeenCalled() + expect(refreshDir).toHaveBeenCalledTimes(1) + }) + + it('does not start a second run while the first is still in flight', async () => { + const gate = createDeferred() + const { refreshTree, scheduler } = setup({ refreshTree: () => gate.promise }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + expect(refreshTree).toHaveBeenCalledTimes(1) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + expect(refreshTree).toHaveBeenCalledTimes(1) + + gate.resolve() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshTree).toHaveBeenCalledTimes(2) + }) + + it('flushes a dir request that arrived during an in-flight run', async () => { + const gate = createDeferred() + const { refreshDir, scheduler } = setup({ refreshTree: () => gate.promise }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + scheduler.requestDirRefresh('/repo/late') + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + expect(refreshDir).not.toHaveBeenCalled() + + gate.resolve() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/late') + }) + + it('never runs more dir refreshes at once than dirConcurrency', async () => { + let inFlight = 0 + let peakInFlight = 0 + const refreshDir = vi.fn(async (_dirPath: string) => { + inFlight++ + peakInFlight = Math.max(peakInFlight, inFlight) + await new Promise((resolve) => setTimeout(resolve, 10)) + inFlight-- + }) + const { scheduler } = setup({ refreshDir, dirConcurrency: 4 }) + + for (let index = 0; index < 20; index++) { + scheduler.requestDirRefresh(`/repo/d${index}`) + } + await vi.advanceTimersByTimeAsync(TRAILING_MS + 20 * 10) + + expect(refreshDir).toHaveBeenCalledTimes(20) + expect(peakInFlight).toBeLessThanOrEqual(4) + }) + + it('cancels a pending flush', async () => { + const { refreshTree, refreshDir, scheduler } = setup() + + scheduler.requestFullRefresh() + scheduler.requestDirRefresh('/repo/src') + scheduler.cancel() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + + expect(refreshTree).not.toHaveBeenCalled() + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('stops refreshing queued dirs when cancelled mid-run', async () => { + // Regression guard: refreshDir is bound to a live ref that React reassigns + // before the effect cleanup runs, so a queued path surviving cancel() would + // be read against the NEXT worktree's binding. + const gate = createDeferred() + const refreshDir = vi.fn(async (dirPath: string) => { + if (dirPath === '/repo/d0') { + await gate.promise + } + }) + const { scheduler } = setup({ refreshDir, dirConcurrency: 1 }) + + for (let index = 0; index < 4; index++) { + scheduler.requestDirRefresh(`/repo/d${index}`) + } + await vi.advanceTimersByTimeAsync(TRAILING_MS) + expect(refreshDir).toHaveBeenCalledTimes(1) + + scheduler.cancel() + gate.resolve() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + + expect(refreshDir).toHaveBeenCalledTimes(1) + }) + + it('still refreshes a dir first expanded while the tree refresh was in flight', async () => { + const gate = createDeferred() + const expanded = new Set() + const { refreshDir, scheduler } = setup({ + refreshTree: () => gate.promise, + isCoveredByFullRefresh: (dirPath) => expanded.has(dirPath) + }) + + scheduler.requestDirRefresh('/repo/late') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + // refreshTree already ran against the old expanded set, so this dir was + // never re-read and its pending refresh must survive. + expanded.add('/repo/late') + gate.resolve() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/late') + }) + + it('drops a dir queued during an in-flight run when cancel lands first', async () => { + const gate = createDeferred() + const { refreshTree, refreshDir, scheduler } = setup({ refreshTree: () => gate.promise }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + scheduler.requestDirRefresh('/repo/src') + scheduler.cancel() + + gate.resolve() + await vi.advanceTimersByTimeAsync(MAX_WAIT_MS) + + expect(refreshTree).toHaveBeenCalledTimes(1) + expect(refreshDir).not.toHaveBeenCalled() + }) + + it('uses the injected timer functions', async () => { + const schedule = vi.fn((callback: () => void, delayMs: number) => setTimeout(callback, delayMs)) + const clear = vi.fn((timer: ReturnType) => { + clearTimeout(timer) + }) + const { refreshTree, scheduler } = setup({ schedule, clear }) + + scheduler.requestFullRefresh() + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(schedule).toHaveBeenCalledTimes(2) + expect(clear).toHaveBeenCalledTimes(1) + expect(refreshTree).toHaveBeenCalledTimes(1) + }) + + it('keeps a full refresh ahead of the dir refreshes it does not cover', async () => { + const order: string[] = [] + const { scheduler } = setup({ + refreshTree: async (): Promise => { + order.push('tree') + return 'refreshed' + }, + refreshDir: async (dirPath) => { + order.push(dirPath) + } + }) + + scheduler.requestDirRefresh('/repo/collapsed') + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(TRAILING_MS) + + expect(order).toEqual(['tree', '/repo/collapsed']) + }) + + // Why: local workspaces pass a zero window because the main process already coalesced the burst + // on the same 150/500 timings. Stacking a second debounce there only doubled paint latency. + describe('zero window (local transport)', () => { + const zeroWindow = { trailingMs: 0, maxWaitMs: 0 } + + it('flushes on the next tick instead of waiting out a trailing window', async () => { + const { refreshTree, scheduler } = setup(zeroWindow) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(0) + + expect(refreshTree).toHaveBeenCalledTimes(1) + }) + + it('still coalesces one payload burst into a single refresh', async () => { + const { refreshTree, refreshDir, scheduler } = setup(zeroWindow) + + for (let index = 0; index < 20; index++) { + scheduler.requestFullRefresh() + scheduler.requestDirRefresh('/repo/src') + } + await vi.advanceTimersByTimeAsync(0) + + expect(refreshTree).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledTimes(1) + expect(refreshDir).toHaveBeenCalledWith('/repo/src') + }) + + it('does not start a second run while one is in flight', async () => { + const deferred = createDeferred() + const { refreshTree, scheduler } = setup({ + ...zeroWindow, + refreshTree: () => deferred.promise + }) + + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(0) + scheduler.requestFullRefresh() + await vi.advanceTimersByTimeAsync(0) + + expect(refreshTree).toHaveBeenCalledTimes(1) + + deferred.resolve() + await vi.advanceTimersByTimeAsync(0) + + expect(refreshTree).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.ts new file mode 100644 index 00000000000..246ead05519 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.ts @@ -0,0 +1,152 @@ +import type { FileExplorerTreeRefreshOutcome } from './file-explorer-types' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../../../shared/filesystem-watch-batch-window' +import { forEachWithConcurrency } from '../../../../shared/map-with-concurrency' + +type SchedulerTimer = ReturnType + +export type FileExplorerWatchRefreshScheduler = { + requestFullRefresh: () => void + requestDirRefresh: (dirPath: string) => void + cancel: () => void +} + +export type CreateFileExplorerWatchRefreshSchedulerParams = { + refreshTree: () => Promise + refreshDir: (dirPath: string) => Promise + /** Dirs a full tree refresh already re-reads; the scheduler has no view of `expanded`. */ + isCoveredByFullRefresh: (dirPath: string) => boolean + dirConcurrency: number + trailingMs?: number + maxWaitMs?: number + schedule?: (callback: () => void, delayMs: number) => SchedulerTimer + clear?: (timer: SchedulerTimer) => void +} + +/** + * Coalesces watcher-driven File Explorer refreshes into one debounced run. + * + * Why: an overflow storm otherwise issues K × (1 + N_expanded) readDirectory + * round trips on the same SSH mux that carries PTY output. Transport-agnostic on + * purpose — it sits above both the Electron fs:changed bus and the runtime-RPC + * file-change subscription. + */ +export function createFileExplorerWatchRefreshScheduler({ + refreshTree, + refreshDir, + isCoveredByFullRefresh, + dirConcurrency, + trailingMs = WATCH_BATCH_TRAILING_MS, + maxWaitMs = WATCH_BATCH_MAX_WAIT_MS, + schedule = setTimeout, + clear = clearTimeout +}: CreateFileExplorerWatchRefreshSchedulerParams): FileExplorerWatchRefreshScheduler { + let pendingFull = false + const pendingDirs = new Map() + let firstRequestAt: number | null = null + let timer: SchedulerTimer | null = null + let inFlight: Promise | null = null + let disposed = false + + function clearTimer(): void { + if (timer !== null) { + clear(timer) + timer = null + } + } + + function arm(delayMs: number): void { + clearTimer() + timer = schedule(() => { + timer = null + // Why: a run started here would race the in-flight one; the settle + // handler re-arms instead, so nothing pending is dropped. + if (inFlight === null) { + run() + } + }, delayMs) + } + + function armForRequest(): void { + firstRequestAt ??= Date.now() + arm(Math.max(0, Math.min(trailingMs, firstRequestAt + maxWaitMs - Date.now()))) + } + + function run(): void { + const full = pendingFull + const dirs = Array.from(pendingDirs.values()) + pendingFull = false + pendingDirs.clear() + firstRequestAt = null + + const started = (async () => { + // Why: isCoveredByFullRefresh reads a live `expanded` ref, so resolve it + // against the set refreshTree is about to read — a dir expanded during the + // tree refresh was never re-read and must keep its pending refresh. + const covered = full ? new Set(dirs.filter(isCoveredByFullRefresh)) : null + const outcome = full && !disposed ? await refreshTree() : null + // Why: refreshTree bails without touching the expanded dirs when its root load is + // superseded, so trusting `covered` there would drop those refreshes for good — nothing + // marks an expanded dir stale, so a later re-expansion won't re-read it either. + // Why: 'root-unreadable' is the opposite case. The transport just failed a read, so + // re-issuing buys one dead timeout per dir and holds `inFlight` open the whole time, + // blocking every later refresh. Drop them; a recovered watcher resyncs with an overflow. + const outstanding = + outcome === 'root-unreadable' + ? [] + : covered && outcome === 'refreshed' + ? dirs.filter((dirPath) => !covered.has(dirPath)) + : dirs + // Why: forEachWithConcurrency has no cancel hook, and the bound refreshDir + // is a live ref — a queued path would otherwise be read against the next + // worktree's binding after a switch. + await forEachWithConcurrency(outstanding, dirConcurrency, (dirPath) => + disposed ? Promise.resolve() : refreshDir(dirPath) + ) + })() + inFlight = started + void started + .catch(() => { + // Refresh failures are transient; the next watcher event retries. + }) + .finally(() => { + if (inFlight === started) { + inFlight = null + } + if (!disposed && (pendingFull || pendingDirs.size > 0)) { + arm(trailingMs) + } + }) + } + + return { + requestFullRefresh: () => { + if (disposed) { + return + } + // Why: pendingDirs is deliberately NOT cleared. toggleDir drops a dir from + // expandedDirs without purging dirCache, so cache keys are a strict + // superset of what refreshTree re-reads; subsuming them would strand a + // stale listing for a collapsed-but-cached dir. + pendingFull = true + armForRequest() + }, + requestDirRefresh: (dirPath: string) => { + if (disposed) { + return + } + pendingDirs.set(normalizeRuntimePathForComparison(dirPath), dirPath) + armForRequest() + }, + cancel: () => { + disposed = true + clearTimer() + pendingFull = false + pendingDirs.clear() + firstRequestAt = null + } + } +} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerManualRefresh.ts b/src/renderer/src/components/right-sidebar/useFileExplorerManualRefresh.ts index 36fc1d24caf..b5c48de77da 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerManualRefresh.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerManualRefresh.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' const REFRESH_SPINNER_DELAY_MS = 200 -export function useFileExplorerManualRefresh(refreshTree: () => Promise): { +export function useFileExplorerManualRefresh(refreshTree: () => Promise): { isRefreshing: boolean showRefreshSpinner: boolean handleRefresh: () => void diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx new file mode 100644 index 00000000000..9c665f4cce6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DirEntry } from '../../../../shared/types' +import { useFileExplorerTree } from './useFileExplorerTree' + +const readDirectoryMock = vi.hoisted(() => vi.fn()) +vi.mock('./file-explorer-directory-listing', async (importOriginal) => ({ + ...(await importOriginal>()), + readFileExplorerDirectory: readDirectoryMock +})) +vi.mock('./file-explorer-operation-owner', async (importOriginal) => ({ + ...(await importOriginal>()), + getFileExplorerOperationOwner: () => ({ kind: 'local' as const }) +})) + +function entry(name: string, isDirectory = false): DirEntry { + return { name, isDirectory } as DirEntry +} + +function listing(...entries: DirEntry[]) { + return { entries, operationOwner: { kind: 'local' as const } } +} + +describe('useFileExplorerTree stale collapsed dirs', () => { + beforeEach(() => { + readDirectoryMock.mockReset() + readDirectoryMock.mockResolvedValue(listing()) + }) + + afterEach(() => { + cleanup() + }) + + it('marks a collapsed cached dir the full refresh skipped, and clears it on the next read', async () => { + const { result } = renderHook(() => useFileExplorerTree('/repo', new Set(), 'wt-1')) + + readDirectoryMock.mockResolvedValueOnce(listing(entry('gone.ts'))) + await act(async () => { + await result.current.loadDir('/repo/src', 0) + }) + expect(result.current.dirCache['/repo/src'].children).toHaveLength(1) + expect(result.current.isDirStale('/repo/src')).toBe(false) + + // The overflow refresh reads root and the (empty) expanded set — not the collapsed /repo/src. + readDirectoryMock.mockClear() + await act(async () => { + await result.current.refreshTree() + }) + expect(readDirectoryMock).not.toHaveBeenCalledWith('wt-1', '/repo', '/repo/src') + expect(result.current.isDirStale('/repo/src')).toBe(true) + // Its cached children stay put until something re-reads them. + expect(result.current.dirCache['/repo/src'].children).toHaveLength(1) + + await act(async () => { + await result.current.loadDir('/repo/src', 0, { force: true }) + }) + expect(result.current.isDirStale('/repo/src')).toBe(false) + expect(result.current.dirCache['/repo/src'].children).toHaveLength(0) + }) + + it('does not mark the root or a dir the full refresh re-read', async () => { + const expanded = new Set(['/repo/src']) + const { result } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1')) + + await act(async () => { + await result.current.loadDir('/repo/src', 0) + }) + await act(async () => { + await result.current.refreshTree() + }) + + expect(result.current.isDirStale('/repo')).toBe(false) + expect(result.current.isDirStale('/repo/src')).toBe(false) + }) + + it('keeps a mark a superseded refresh never verified, even once the dir is expanded', async () => { + // Regression guard: refreshTree used to REPLACE the mark set. A dir marked while collapsed and + // then expanded was dropped by the replacement (expanded dirs are never marked) — and when that + // refresh bailed on a superseded root load, nothing re-read it and nothing still called it + // unverified, so re-expanding served the pre-overflow listing forever. + const expanded = new Set() + const { result, rerender } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1')) + + readDirectoryMock.mockResolvedValueOnce(listing(entry('gone.ts'))) + await act(async () => { + await result.current.loadDir('/repo/src', 0) + }) + await act(async () => { + await result.current.refreshTree() + }) + expect(result.current.isDirStale('/repo/src')).toBe(true) + + expanded.add('/repo/src') + rerender() + + let releaseRoot!: () => void + const rootGate = new Promise((resolve) => { + releaseRoot = resolve + }) + readDirectoryMock.mockImplementationOnce(async () => { + await rootGate + return listing() + }) + + let refresh!: Promise + act(() => { + refresh = result.current.refreshTree() + }) + await act(async () => { + // A concurrent root read supersedes this refresh's token, so it bails before the expanded set. + void result.current.refreshDir('/repo') + releaseRoot() + expect(await refresh).toBe('superseded') + }) + + expect(result.current.isDirStale('/repo/src')).toBe(true) + }) + + it('does not read the expanded dirs when the root read failed', async () => { + // Regression guard: loadDir swallows read errors unless failOnError is set, so a dead + // transport used to report a completed root read and fan out one doomed wave per 4 dirs. + const expanded = new Set(['/repo/a', '/repo/b']) + const { result } = renderHook(() => useFileExplorerTree('/repo', expanded, 'wt-1')) + + readDirectoryMock.mockReset() + readDirectoryMock.mockRejectedValue(new Error('ETIMEDOUT')) + + await act(async () => { + expect(await result.current.refreshTree()).toBe('root-unreadable') + }) + + // Only the root read was attempted; the expanded dirs were not. + expect(readDirectoryMock).toHaveBeenCalledTimes(1) + expect(readDirectoryMock).toHaveBeenCalledWith('wt-1', '/repo', '/repo') + }) + + it('drops stale marks when the tree is reset for a new worktree', async () => { + const { result } = renderHook(() => useFileExplorerTree('/repo', new Set(), 'wt-1')) + + await act(async () => { + await result.current.loadDir('/repo/src', 0) + }) + await act(async () => { + await result.current.refreshTree() + }) + expect(result.current.isDirStale('/repo/src')).toBe(true) + + await act(async () => { + result.current.resetAndLoad() + }) + expect(result.current.isDirStale('/repo/src')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index 591d9fd4d9f..d6f695c2ac3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,12 +1,9 @@ import type { Dispatch, SetStateAction } from 'react' import { useCallback, useRef, useState } from 'react' -import type { DirCache } from './file-explorer-types' +import type { DirCache, FileExplorerTreeRefreshOutcome } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { statRuntimePath } from '@/runtime/runtime-file-client' -import { - createFileExplorerDirLoadTracker, - type FileExplorerDirLoadTracker -} from './file-explorer-dir-load-tracker' +import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' import { getFileExplorerOperationOwner, getFileExplorerOwnerUnresolvedMessage, @@ -14,9 +11,11 @@ import { } from './file-explorer-operation-owner' import { fileExplorerEntriesToTreeNodes, - readFileExplorerDirectory, - type FileExplorerDirectoryListing + readFileExplorerDirectory } from './file-explorer-directory-listing' +import { refreshFileExplorerExpandedDirs } from './file-explorer-expanded-dirs-refresh' +import { collectStaleDirCachePaths } from './file-explorer-stale-dir-cache' +import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency' type UseFileExplorerTreeResult = { dirCache: Record @@ -30,113 +29,13 @@ type UseFileExplorerTreeResult = { ) => Promise statPath: (path: string) => Promise<{ isDirectory: boolean }> markPathAsDirectory: (path: string) => void - refreshTree: () => Promise + refreshTree: () => Promise refreshDir: (dirPath: string) => Promise + /** True while a dir's cached listing predates the last full refresh that skipped it. */ + isDirStale: (dirPath: string) => boolean resetAndLoad: () => void } -type RefreshFileExplorerTreeDir = { - dirPath: string - depth: number -} - -type RefreshFileExplorerExpandedDirsParams = { - dirs: RefreshFileExplorerTreeDir[] - worktreePath: string - dirLoadTracker: FileExplorerDirLoadTracker - setDirCache: Dispatch>> - readDirectory: (dirPath: string) => Promise -} - -export async function refreshFileExplorerExpandedDirs({ - dirs, - worktreePath, - dirLoadTracker, - setDirCache, - readDirectory -}: RefreshFileExplorerExpandedDirsParams): Promise { - if (dirs.length === 0) { - return true - } - - const uniqueDirs = Array.from(new Map(dirs.map((dir) => [dir.dirPath, dir])).values()) - const loadTokens = new Map( - uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)]) - ) - - // Why: expanded refresh can touch many directories; commit the loading and - // result states in two batched setDirCache writes (rather than per-directory) - // so refreshing large worktrees stays O(N) instead of O(N²) cache spreads. - setDirCache((prev) => { - const next = { ...prev } - for (const { dirPath } of uniqueDirs) { - next[dirPath] = { - children: prev[dirPath]?.children ?? [], - loading: true - } - } - return next - }) - - const results = await Promise.all( - uniqueDirs.map(async ({ dirPath, depth }) => { - const loadToken = loadTokens.get(dirPath)! - try { - const listing = await readDirectory(dirPath) - if (!dirLoadTracker.isCurrent(loadToken)) { - return { current: false as const } - } - return { - current: true as const, - dirPath, - cache: { - children: fileExplorerEntriesToTreeNodes( - listing.entries, - dirPath, - depth, - worktreePath, - listing.operationOwner - ), - loading: false, - operationOwner: listing.operationOwner - } - } - } catch { - if (!dirLoadTracker.isCurrent(loadToken)) { - return { current: false as const } - } - return { - current: true as const, - dirPath, - cache: { children: [], loading: false } - } - } - }) - ) - - // Why: the batch commits only after the slowest read, so a dir can be - // superseded (watcher refreshDir, worktree reset) after its own read - // resolved. Re-check tokens at commit time so the batched write never - // clobbers a newer load — preserving the old per-dir commit ordering. - const currentResults = results.filter( - (result): result is Extract => - result.current && dirLoadTracker.isCurrent(loadTokens.get(result.dirPath)!) - ) - if (currentResults.length === 0) { - return false - } - - setDirCache((prev) => { - const next = { ...prev } - for (const result of currentResults) { - next[result.dirPath] = result.cache - } - return next - }) - - return currentResults.length === uniqueDirs.length -} - export function useFileExplorerTree( worktreePath: string | null, expanded: Set, @@ -147,6 +46,11 @@ export function useFileExplorerTree( const dirCacheRef = useRef(dirCache) dirCacheRef.current = dirCache const dirLoadTrackerRef = useRef(createFileExplorerDirLoadTracker()) + // Why: a ref, not state — the expansion effect must read the mark set by a refresh that landed + // after the effect's render, and a state write would only be visible one render too late. + const staleDirsRef = useRef(new Set()) + // Why: separates a failed root read from a superseded one — loadDir returns false for both. + const rootReadFailedRef = useRef(false) const loadDir = useCallback( async ( @@ -159,6 +63,8 @@ export function useFileExplorerTree( return true } const loadToken = dirLoadTrackerRef.current.begin(dirPath) + // Why: this read starts after the refresh that marked the dir, so its result is current. + staleDirsRef.current.delete(dirPath) // Why: when force-reloading a directory (e.g. after a file is created, // duplicated, or deleted), keep the previous children visible while the // fresh listing loads. Clearing to [] would momentarily shrink the @@ -200,6 +106,7 @@ export function useFileExplorerTree( // empty worktree. Preserve the message so the UI can distinguish // "no files" from "could not read this worktree". setRootError(error instanceof Error ? error.message : String(error)) + rootReadFailedRef.current = true } setDirCache((prev) => ({ ...prev, [dirPath]: { children: [], loading: false } })) return !options?.failOnError @@ -248,17 +155,42 @@ export function useFileExplorerTree( [activeWorktreeId, worktreePath] ) - const refreshTree = useCallback(async () => { + const refreshTree = useCallback(async (): Promise => { if (!worktreePath) { - return + // Why: not 'root-unreadable' — no read was attempted, and that outcome tells callers to DROP + // their pending refreshes. Report the refresh as not-done so they keep them instead. + return 'superseded' } // Why: clearing the entire dirCache here would momentarily empty the // visible projection and jump the virtualizer to the top. Instead we rely // on force-reload keeping existing children visible until fresh data lands. + // Why: mark before the first read, against the live expanded set — a dir expanded after this + // point loads through the expansion effect, which would otherwise trust a listing this refresh + // never re-read. + // Why: union, not replace. This refresh can bail below without re-reading a single expanded + // dir, so replacing would erase an earlier mark for a dir nothing has verified since. Marks are + // cleared only where a read actually lands: loadDir, and onDirCommitted per committed wave. + // Why: prune first — a mark only means anything while the dir still has a cached listing to + // distrust, and unioning without this would accumulate every path visited in the session. + for (const dirPath of staleDirsRef.current) { + if (dirCacheRef.current[dirPath] === undefined) { + staleDirsRef.current.delete(dirPath) + } + } + // Why: callers use the latest refreshTree identity, so this closure has the live expanded set. + for (const dirPath of collectStaleDirCachePaths(dirCacheRef.current, worktreePath, expanded)) { + staleDirsRef.current.add(dirPath) + } const refreshSession = dirLoadTrackerRef.current.getSession() - const rootLoadCompleted = await loadDir(worktreePath, -1, { force: true }) + rootReadFailedRef.current = false + // Why: failOnError, else a dead transport reports a completed root read and we fan out one + // doomed wave per 4 expanded dirs — 200 dirs is ~50 sequential 15s timeouts, and the watch + // scheduler cannot start another refresh for that entire window. + const rootLoadCompleted = await loadDir(worktreePath, -1, { force: true, failOnError: true }) if (!rootLoadCompleted || !dirLoadTrackerRef.current.isSessionCurrent(refreshSession)) { - return + // Why: the expanded dirs below were never re-read either way, but the two reasons want + // opposite handling from callers — see FileExplorerTreeRefreshOutcome. + return rootReadFailedRef.current ? 'root-unreadable' : 'superseded' } // Why: root (worktreePath) was just force-loaded above; exclude it here so // refreshFileExplorerExpandedDirs doesn't queue a duplicate read of root. @@ -268,13 +200,19 @@ export function useFileExplorerTree( dirPath, depth: splitPathSegments(dirPath.slice(worktreePath.length + 1)).length - 1 })) - await refreshFileExplorerExpandedDirs({ + const allDirsCommitted = await refreshFileExplorerExpandedDirs({ dirs: expandedDirs, worktreePath, dirLoadTracker: dirLoadTrackerRef.current, setDirCache, - readDirectory: (dirPath) => readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath) + readDirectory: (dirPath) => + readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath), + maxConcurrentReads: fileExplorerRefreshConcurrency( + getFileExplorerOperationOwner(activeWorktreeId) + ), + onDirCommitted: (dirPath) => staleDirsRef.current.delete(dirPath) }) + return allDirsCommitted ? 'refreshed' : 'superseded' }, [activeWorktreeId, expanded, loadDir, worktreePath]) const refreshDir = useCallback( @@ -291,12 +229,15 @@ export function useFileExplorerTree( [worktreePath, loadDir] ) + const isDirStale = useCallback((dirPath: string) => staleDirsRef.current.has(dirPath), []) + const rootCache = worktreePath ? dirCache[worktreePath] : undefined const resetAndLoad = useCallback(() => { // Why: stale readDir responses from the previous worktree/reset session // must not repopulate the explorer after the tree has been cleared. dirLoadTrackerRef.current.reset() + staleDirsRef.current.clear() setDirCache({}) setRootError(null) if (worktreePath) { @@ -314,6 +255,7 @@ export function useFileExplorerTree( markPathAsDirectory, refreshTree, refreshDir, + isDirStale, resetAndLoad } } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx index b6dd71c8a05..00ffeee274d 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx @@ -22,24 +22,28 @@ function useProjection(query: string) { }) } -function useTreeProjection() { +function treeDirCache(loading: boolean) { + return { + '/repo': { + children: [ + { + name: 'src', + path: '/repo/src', + relativePath: 'src', + isDirectory: true, + depth: 0 + } + ], + loading + } + } +} + +function useTreeProjection(dirCache = treeDirCache(false)) { return useFileExplorerVisibleRowProjection( 'worktree-1', '/repo', - { - '/repo': { - children: [ - { - name: 'src', - path: '/repo/src', - relativePath: 'src', - isDirectory: true, - depth: 0 - } - ], - loading: false - } - }, + dirCache, new Set(), true, true, @@ -95,4 +99,19 @@ describe('file explorer ignored-path query debounce', () => { expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1) expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toEqual(['src']) }) + + it('does not re-issue the ignored check for a dirCache commit that changes no path', () => { + // A wave-batched tree refresh commits a fresh dirCache object per wave. The + // ignored query is an uncancellable remote git check-ignore over the whole + // visible tree, so identical contents must not re-issue it. + const hook = renderHook(({ dirCache }) => useTreeProjection(dirCache), { + initialProps: { dirCache: treeDirCache(false) } + }) + expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1) + + hook.rerender({ dirCache: treeDirCache(true) }) + hook.rerender({ dirCache: treeDirCache(false) }) + + expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index 813555af1c5..06a5e110ad4 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -109,6 +109,28 @@ export function createVisibleFileExplorerRowProjection( return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath) } +/** + * Holds the array identity while its contents are unchanged. + * + * Why: a tree refresh commits dirCache once per read wave, and every commit + * rebuilds this list. Each new identity would re-issue the uncancellable git + * check-ignore over the whole visible tree — the remote round trips the wave cap + * exists to bound. + */ +function useContentStableRelativePaths(relativePaths: string[], enabled: boolean): string[] { + // Why: filters need fresh identities per keystroke and must not evict the tree signature. + // Why: NUL cannot occur in paths, so the signature can reconstruct the list losslessly. + const signature = useMemo( + () => (enabled ? relativePaths.join('\u0000') : null), + [enabled, relativePaths] + ) + const stableTreePaths = useMemo( + () => (signature ? signature.split('\u0000') : EMPTY_RELATIVE_PATHS), + [signature] + ) + return enabled ? stableTreePaths : relativePaths +} + export function useFileExplorerVisibleRowProjection( activeWorktreeId: string | null, worktreePath: string | null, @@ -128,7 +150,7 @@ export function useFileExplorerVisibleRowProjection( const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true - const relativePaths = useMemo( + const rebuiltRelativePaths = useMemo( () => activeRepoSupportsGit ? nameFilter @@ -140,6 +162,9 @@ export function useFileExplorerVisibleRowProjection( : EMPTY_RELATIVE_PATHS, [activeRepoSupportsGit, dirCache, expanded, nameFilter, showDotfiles, worktreePath] ) + // Why: the name-filter list is debounced per keystroke, so it must keep a fresh + // identity; only the dirCache-derived list needs stability across wave commits. + const relativePaths = useContentStableRelativePaths(rebuiltRelativePaths, !nameFilter) const canLoadIgnoredPaths = activeRepoSupportsGit && Boolean(activeWorktreeId) && diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index be089be1666..c75dfd4fac8 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -1,13 +1,21 @@ import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react' import type { FsChangedPayload } from '../../../../shared/types' -import type { DirCache, FileExplorerOperationOwner } from './file-explorer-types' +import type { + DirCache, + FileExplorerOperationOwner, + FileExplorerTreeRefreshOutcome +} from './file-explorer-types' import type { InlineInput } from './FileExplorerRow' import { useAppStore } from '@/store' import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { + getFileExplorerOperationOwner, getFileExplorerOperationOwnerFromState, type FileExplorerOwnerState } from './file-explorer-operation-owner' +import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency' +import { createFileExplorerWatchRefreshScheduler } from './file-explorer-watch-refresh-scheduler' import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile' export { @@ -30,7 +38,7 @@ type UseFileExplorerWatchParams = { expanded: Set setSelectedPath: Dispatch> refreshDir: (dirPath: string) => Promise - refreshTree: () => Promise + refreshTree: () => Promise inlineInput: InlineInput | null dragSourcePath: string | null isNativeDragOver: boolean @@ -127,6 +135,29 @@ export function useFileExplorerWatch({ const currentWorktreePath = worktreePath + // Why: one scheduler per subscription covers BOTH remote transports (the + // Electron fs:changed bus and the runtime-RPC subscription below), and its + // lifetime is tied to the watched worktree so a switch can't refresh the + // previous root. + const owner = getFileExplorerOperationOwner(worktreeIdRef.current) + const scheduler = createFileExplorerWatchRefreshScheduler({ + refreshTree: () => refreshTreeRef.current(), + refreshDir: (dirPath) => refreshDirRef.current(dirPath), + // Why exact-match on `expanded`, not a normalized compare: dirPath already arrives as a + // resolved dirCache key (resolveCachedDirPath), and refreshTree re-reads dirCache under the + // verbatim expanded strings — a differently-spelled key it never wrote would be reported + // covered and left stale. Only the root is normalized: it is keyed by worktreePath. + isCoveredByFullRefresh: (dirPath) => + normalizeRuntimePathForComparison(dirPath) === + normalizeRuntimePathForComparison(currentWorktreePath) || + expandedRef.current.has(dirPath), + dirConcurrency: fileExplorerRefreshConcurrency(owner), + // Why: main already coalesced the burst on this same 150/500 window before it reached the + // fs:changed bus, so a second debounce here only doubles paint latency. Zero still arms a 0ms + // timer, keeping intra-payload dedup and the concurrency cap. + ...(owner.kind === 'local' ? { trailingMs: 0, maxWaitMs: 0 } : {}) + }) + function processPayload(payload: FsChangedPayload): void { const wtId = worktreeIdRef.current if (!wtId) { @@ -140,12 +171,8 @@ export function useFileExplorerWatch({ expanded: expandedRef.current, setDirCache, setSelectedPath, - refreshDir: (dirPath) => { - void refreshDirRef.current(dirPath) - }, - refreshTree: () => { - void refreshTreeRef.current() - } + refreshDir: scheduler.requestDirRefresh, + refreshTree: scheduler.requestFullRefresh }) } @@ -207,6 +234,7 @@ export function useFileExplorerWatch({ return () => { disposed = true unsubscribeListener?.() + scheduler.cancel() deferredRef.current = [] processPayloadRef.current = null } diff --git a/src/shared/filesystem-watch-batch-window.ts b/src/shared/filesystem-watch-batch-window.ts new file mode 100644 index 00000000000..fbbae133b78 --- /dev/null +++ b/src/shared/filesystem-watch-batch-window.ts @@ -0,0 +1,5 @@ +// Why: the main-process watcher batcher and the renderer's Explorer refresh +// scheduler share one flush window so local and remote latency can't drift. + +export const WATCH_BATCH_TRAILING_MS = 150 +export const WATCH_BATCH_MAX_WAIT_MS = 500 From b04c695750e9859e0d8bf79f503279bc4d656925 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:57:24 -0700 Subject: [PATCH 9/9] fix(runtime): drop stale local agent rows from worktree.ps after tab close (#11464) * fix(runtime): drop stale local agent rows from worktree.ps after tab close attachAgentRowsToSummaries attached every hydrated hook row by worktreeId with no check that the pane/tab still exists, so agents from closed tabs (last-status.json hydrates for days) kept showing on mobile as current activity. Local rows now require the tab in a session/runtime graph or a connected PTY; remote rows are exempt since their tabs may only exist on the remote host. Fixes #6072 * fix(runtime): resolve legacy numeric pane keys through the stale-row filter Non-UUID leaves produce tabId:paneRuntimeId keys with no tabId field; without parsing them the stale filter was bypassed entirely for such rows. * fix(runtime): filter stale WSL agent rows * fix(runtime): ignore persisted tabs for agent liveness * fix(runtime): restore session-tab liveness and thread OSC transport through the stale-row filter Review loop pass 1 (3 independent same-model reviewers, findings converged): - Revert a829e8f9cf's `!this.tabs.has(tabId)` to `mirroredWorktreeId === undefined`. The renderer graph is structurally empty under headless serve (index.ts publishes {tabs: [], leaves: []}), is cleared by markGraphUnavailable, and omits unvisited/cold-parked workspaces, so graph-only existence dropped live agent rows in all those states and broke worktree.ps/session.tabs.list parity. Every close path prunes the persisted tab, so session tabs remain valid liveness evidence; the stale-persisted-tab premise did not survive tracing. - Restore the rename and legacy-pane-key tests to their session-only fixtures (the graph syncs added with the flipped predicate masked the contract change) and pin the restored contract in a named test. - Thread the pane's connectionId through RuntimeAgentRowSnapshot so OSC-retained rows keep the SSH exemption; previously a fresher OSC ping hardcoded null and stripped it. - Pin each rescue conjunct individually (paneKey-only, tabId-only, ptyId after binding clear), the WSL keep direction, the unresolvable-paneKey guard, and row presence in the freshness cases. * fix(runtime): carry the OSC-observed ptyId when a hook row wins the freshness race Hook payloads have no ptyId field, so overwriting the rowSources entry discarded the OSC-observed one and the connected-PTY ptyId rescue went dead for hook-fresh panes during a binding-clear window (pass-2 review P3). Also corrects the incarnation-change comment on the OSC rescue test. --- src/main/runtime/orca-runtime.test.ts | 492 +++++++++++++++++++++++++- src/main/runtime/orca-runtime.ts | 82 ++++- 2 files changed, 564 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 13534a7d560..e89bf7fb214 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -32411,6 +32411,8 @@ describe('OrcaRuntimeService', () => { ] }) + // No renderer graph on purpose: headless rename attribution must work from + // the persisted session alone. const { worktrees } = await runtime.getWorktreePs() const oldSummary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) const renamedSummary = worktrees.find((worktree) => worktree.worktreeId === renamedWorktreeId) @@ -32502,16 +32504,498 @@ describe('OrcaRuntimeService', () => { } ] }) + // Why: local rows only project while their tab exists (#6072); freshness + // is what varies here, so keep the tab present in the runtime graph. + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: '33333333-3333-4333-8333-333333333333', + layout: null + } + ], + leaves: [] + }) const { worktrees } = await runtime.getWorktreePs() - expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({ - hasHostSidebarActivity, - status - }) + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + expect(summary).toMatchObject({ hasHostSidebarActivity, status }) + // Why: inactive must mean "projected but not fresh", never "row dropped". + expect(summary?.agents).toHaveLength(1) } ) + it('drops a hydrated done hook row after its local tab is closed', async () => { + // Why (#6072): last-status.json hydrates hook rows for days; a closed tab's + // agent must not resurface on mobile as current worktree activity. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'closed-tab:66666666-6666-4666-8666-666666666666', + worktreeId: TEST_WORKTREE_ID, + tabId: 'closed-tab', + state: 'done', + prompt: 'refactor the parser', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 60_000 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ + liveTerminalCount: 0, + hasHostSidebarActivity: false, + status: 'inactive', + agents: [] + }) + }) + + it('keeps a hydrated row while its persisted session tab exists and no renderer graph is attached', async () => { + // Why: headless serve has no renderer graph; session.tabs.list serves this + // tab to mobile as current, so its agent row must stay (desktop parity). + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'headless-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'headless-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + worktreeId: TEST_WORKTREE_ID, + tabId: 'headless-tab', + state: 'done', + prompt: 'finished while headless', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 60_000 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ state: 'done', prompt: 'finished while headless' }) + ]) + }) + + it('resolves legacy numeric pane keys through the stale filter too', async () => { + // Why: non-UUID leaves produce `tabId:paneRuntimeId` keys with no tabId + // field; they still name a real tab and must not bypass the filter. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'open-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'closed-tab:7', + worktreeId: TEST_WORKTREE_ID, + state: 'done', + prompt: 'stale legacy pane', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 60_000 + }, + { + paneKey: 'open-tab:9', + worktreeId: TEST_WORKTREE_ID, + state: 'working', + prompt: 'live legacy pane', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ paneKey: 'open-tab:9', prompt: 'live legacy pane' }) + ]) + }) + + it('keeps a local hook row while a connected PTY still backs its pane', async () => { + // Why: daemon-held terminals stay live across renderer graph gaps even when + // no session tab records them; a connected PTY is proof the pane exists. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const paneKey = 'daemon-tab:77777777-7777-4777-8777-777777777777' + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'daemon-tab', + state: 'working', + prompt: 'long-running daemon agent', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + // paneKey-only record: the tabId rescue must not be what keeps this row. + runtime['recordPtyWorktree']('daemon-pty', TEST_WORKTREE_ID, { + connected: true, + paneKey + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ paneKey, state: 'working', prompt: 'long-running daemon agent' }) + ]) + expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) + }) + + it('keeps a local hook row when a connected PTY matches only its tab id', async () => { + // Split-pane sibling: the PTY's paneKey names another leaf of the same tab, + // so only the tabId conjunct can rescue this row. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'daemon-tab:88888888-8888-4888-8888-888888888887', + worktreeId: TEST_WORKTREE_ID, + tabId: 'daemon-tab', + state: 'working', + prompt: 'sibling pane agent', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + runtime['recordPtyWorktree']('daemon-pty-2', TEST_WORKTREE_ID, { + connected: true, + tabId: 'daemon-tab', + paneKey: 'daemon-tab:99999999-9999-4999-8999-999999999998' + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ prompt: 'sibling pane agent', state: 'working' }) + ]) + }) + + it('keeps a retained OSC row via its connected PTY after the pane binding is cleared', async () => { + // A controller incarnation change nulls pty.tabId/paneKey while the PTY + // stays connected (adoptControllerTerminalHandle); the ptyId conjunct is + // then the only rescue for the retained OSC row. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime['recordPtyWorktree']('osc-pty', TEST_WORKTREE_ID, { + connected: true, + tabId: 'osc-tab', + paneKey: 'osc-tab:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + }) + runtime.onPtyData( + 'osc-pty', + '\x1b]9999;{"state":"working","prompt":"osc reporter","agentType":"codex"}\x07', + 1 + ) + const pty = runtime['ptysById'].get('osc-pty')! + pty.tabId = null + pty.paneKey = null + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'osc reporter' })]) + }) + + it('keeps the connected-PTY rescue when a hook row outraces the OSC row for the same pane', async () => { + // Hook payloads carry no ptyId; the OSC-observed one must survive the + // hook row winning the freshness race or the ptyId rescue goes dead. + const paneKey = 'race-tab:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + worktreeId: TEST_WORKTREE_ID, + tabId: 'race-tab', + state: 'working', + prompt: 'hook-fresh agent', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now() + 60_000, + stateStartedAt: Date.now() - 100 + } + ] + }) + runtime['recordPtyWorktree']('race-pty', TEST_WORKTREE_ID, { + connected: true, + tabId: 'race-tab', + paneKey + }) + runtime.onPtyData( + 'race-pty', + '\x1b]9999;{"state":"working","prompt":"osc ping","agentType":"codex"}\x07', + 1 + ) + const pty = runtime['ptysById'].get('race-pty')! + pty.tabId = null + pty.paneKey = null + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'hook-fresh agent' })]) + }) + + it('keeps a retained OSC row from an SSH pane after its PTY disconnects', async () => { + // Why: OSC snapshots must carry the pane transport; hardcoding local would + // strip the SSH exemption off rows whose freshest update arrived via OSC. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, { + connected: true, + connectionId: 'ssh-osc-1', + tabId: 'ssh-tab', + paneKey: 'ssh-tab:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + }) + runtime.onPtyData( + 'ssh-osc-pty', + '\x1b]9999;{"state":"working","prompt":"remote osc agent","agentType":"codex"}\x07', + 1 + ) + runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, { connected: false }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'remote osc agent' })]) + }) + + it('keeps a hook row with an unresolvable pane key', async () => { + // Why: no tabId means staleness is unprovable; the filter must pass it. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'opaque-key-without-structure', + worktreeId: TEST_WORKTREE_ID, + state: 'working', + prompt: 'unattributable pane', + agentType: 'codex', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'unattributable pane' })]) + }) + + it('keeps a WSL hook row while its tab is still in the session', async () => { + // Guards the WSL clause against over-filtering: local-tab evidence must + // rescue WSL rows exactly like null-connection rows. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'open-wsl-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'open-wsl-tab:99999999-9999-4999-8999-999999999997', + worktreeId: TEST_WORKTREE_ID, + tabId: 'open-wsl-tab', + state: 'working', + prompt: 'live in WSL', + agentType: 'codex', + connectionId: 'wsl:Ubuntu', + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([expect.objectContaining({ prompt: 'live in WSL' })]) + }) + + it('drops a hydrated WSL hook row after its local tab is closed', async () => { + // Why: WSL relay ids are transport provenance; the pane remains local. + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: {} + }) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'closed-wsl-tab:99999999-9999-4999-8999-999999999999', + worktreeId: TEST_WORKTREE_ID, + tabId: 'closed-wsl-tab', + state: 'done', + prompt: 'finished in WSL', + agentType: 'codex', + connectionId: 'wsl:Ubuntu', + receivedAt: now, + stateStartedAt: now - 60_000 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary?.agents).toEqual([]) + }) + + it('keeps remote hook rows whose tabs are only tracked on the remote host', async () => { + // Why: the local session partition for an SSH host can be empty while the + // remote host owns the terminals; absence there is not proof of a close. + const remoteRepo = { + id: 'repo-ssh-6072', + path: '/home/me/project', + displayName: 'remote-vm', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-6072' + } + const remoteWorktree = { + path: '/home/me/project/.worktrees/feature-agents', + head: 'def', + branch: 'refs/heads/feature/agents', + isBare: false, + isMainWorktree: false + } + const remoteWorktreeId = `${remoteRepo.id}::${remoteWorktree.path}` + const metaById: Record = { + [remoteWorktreeId]: makeWorktreeMeta({ displayName: 'Remote agents' }) + } + const runtimeStore = { + ...store, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession: () => getDefaultWorkspaceSession() + } + registerSshGitProvider('ssh-6072', { + listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) + } as never) + const now = Date.now() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'remote-tab:88888888-8888-4888-8888-888888888888', + worktreeId: remoteWorktreeId, + tabId: 'remote-tab', + state: 'working', + prompt: 'remote agent without local tab records', + agentType: 'codex', + connectionId: 'ssh-6072', + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === remoteWorktreeId) + + expect(summary?.agents).toEqual([ + expect.objectContaining({ prompt: 'remote agent without local tab records' }) + ]) + }) + it('marks the desktop-active worktree as isActive', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 701bfb496e2..963c91811b9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -368,9 +368,15 @@ import { } from '../../shared/setup-agent-sequencing' import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../shared/stable-pane-id' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' +import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { applyTerminalQuickCommandMutation, MAX_QUICK_COMMANDS, @@ -1449,6 +1455,9 @@ type RuntimeAgentRowSnapshot = { ptyId: string worktreeId?: string tabId?: string + // Transport of the pane's PTY at retain time; null for local. Without it, + // OSC rows for SSH panes would lose the remote exemption in worktree.ps. + connectionId: string | null payload: ParsedAgentStatusPayload // When the current payload.state was first observed for this pane (ms). stateStartedAt: number @@ -10056,6 +10065,7 @@ export class OrcaRuntimeService { target.paneKey, target.worktreeId, target.tabId, + target.connectionId ?? null, payload ) || retainedChanged if (!this.onTerminalAgentStatus) { @@ -10086,6 +10096,7 @@ export class OrcaRuntimeService { paneKey: string, worktreeId: string | undefined, tabId: string | undefined, + connectionId: string | null, payload: ParsedAgentStatusPayload ): boolean { const now = Date.now() @@ -10100,6 +10111,7 @@ export class OrcaRuntimeService { ptyId, worktreeId, tabId, + connectionId, payload, stateStartedAt, updatedAt: now @@ -16962,11 +16974,34 @@ export class OrcaRuntimeService { } } + // Why: a connected PTY proves a pane is still live even when its tab has + // already left every session record (daemon-held terminals, graph gaps). + // Deliberately trusts the optimistic connected flag (no freshPtyLiveness + // gate, unlike the count loops above): evidence only KEEPS rows. + const connectedPtyEvidence = { + tabIds: new Set(), + paneKeys: new Set(), + ptyIds: new Set() + } + for (const pty of this.ptysById.values()) { + if (!pty.connected) { + continue + } + connectedPtyEvidence.ptyIds.add(pty.ptyId) + if (pty.tabId) { + connectedPtyEvidence.tabIds.add(pty.tabId) + } + if (pty.paneKey) { + connectedPtyEvidence.paneKeys.add(pty.paneKey) + } + } + this.attachAgentRowsToSummaries( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, - mirroredWorktreeIdByTabId + mirroredWorktreeIdByTabId, + connectedPtyEvidence ) const sorted = [...summaries.values()].sort(compareWorktreePs) @@ -16985,7 +17020,12 @@ export class OrcaRuntimeService { summaries: Map, runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds: Set, - mirroredWorktreeIdByTabId: ReadonlyMap + mirroredWorktreeIdByTabId: ReadonlyMap, + connectedPtyEvidence: { + tabIds: ReadonlySet + paneKeys: ReadonlySet + ptyIds: ReadonlySet + } ): void { // Why: most agents report via hooks (agent-hooks/server), not OSC, so the // hook snapshot is the primary source — same one the desktop sidebar reads. @@ -16994,8 +17034,10 @@ export class OrcaRuntimeService { string, { paneKey: string + ptyId?: string tabId?: string worktreeId?: string + connectionId: string | null state: ParsedAgentStatusPayload['state'] agentType: string | null prompt: string @@ -17011,8 +17053,10 @@ export class OrcaRuntimeService { const { payload } = snapshot rowSources.set(snapshot.paneKey, { paneKey: snapshot.paneKey, + ptyId: snapshot.ptyId, tabId: snapshot.tabId, worktreeId: snapshot.worktreeId, + connectionId: snapshot.connectionId, state: payload.state, agentType: payload.agentType ?? null, prompt: payload.prompt, @@ -17033,8 +17077,12 @@ export class OrcaRuntimeService { } rowSources.set(entry.paneKey, { paneKey: entry.paneKey, + // Hook payloads carry no ptyId; keep the OSC-observed one so the + // connected-PTY rescue survives a hook row winning the freshness race. + ptyId: existing?.ptyId, tabId: entry.tabId, worktreeId: entry.worktreeId, + connectionId: entry.connectionId, state: entry.state, agentType: entry.agentType ?? null, prompt: entry.prompt, @@ -17055,9 +17103,31 @@ export class OrcaRuntimeService { for (const src of rowSources.values()) { // Why: hooks retain launch-time attribution across automatic workspace // renames; the tab's current mirrored owner is authoritative when present. - const tabId = src.tabId ?? parsePaneKey(src.paneKey)?.tabId - const worktreeId = - (tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined) ?? src.worktreeId + // Legacy numeric pane keys (non-UUID leaves) still name a real tab, so + // parse them too — otherwise their rows would bypass the stale filter. + const tabId = + src.tabId ?? + parsePaneKey(src.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(src.paneKey)?.tabId + const mirroredWorktreeId = tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined + if ( + tabId !== undefined && + mirroredWorktreeId === undefined && + (src.connectionId === null || isWslHookRelayConnectionId(src.connectionId)) && + !connectedPtyEvidence.tabIds.has(tabId) && + !connectedPtyEvidence.paneKeys.has(src.paneKey) && + (src.ptyId === undefined || !connectedPtyEvidence.ptyIds.has(src.ptyId)) + ) { + // Why: hook snapshots hydrate from last-status.json for days, so a row + // from a local or WSL-relayed pane whose tab left every session and the + // live graph, with no connected PTY, is retained history — surfacing it + // resurrects closed agents on mobile (#6072). SSH rows are exempt (their + // tabs may exist only remotely), as are rows with no resolvable tabId + // (staleness unprovable). Session tabs count as existence: headless + // serve has no renderer graph, and session.tabs.list serves them. + continue + } + const worktreeId = mirroredWorktreeId ?? src.worktreeId if (!worktreeId) { continue }