Merge remote-tracking branch 'origin/main' into brennanb2025/agent-dashboard-ui-revamp

This commit is contained in:
Brennan Benson
2026-08-01 12:09:23 -07:00
82 changed files with 6269 additions and 381 deletions
+1
View File
@@ -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/*
+180 -1
View File
@@ -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,86 @@
}
},
"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, 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 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"
],
"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",
"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"
]
}
],
"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 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": {
"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. 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.",
"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 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."
},
{
"id": "ssh-relay.staged-upload-recovery",
"title": "SSH relay uploads remain retryable before the shared install lock",
@@ -2970,6 +3050,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",
+2
View File
@@ -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'),
+32
View File
@@ -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<number, string>
}
expect(managerState.tabIdByWebContentsId.has(guest.id)).toBe(false)
})
it('fully unregisters stale guests discovered during authorization', () => {
const guest = {
id: 305,
+6 -3
View File
@@ -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) {
+1
View File
@@ -210,6 +210,7 @@ async function main(): Promise<void> {
timing: {
periodicProbeMs: 2_000,
rejectionRecheckMs: 500,
minimumRejectionSpanMs: 2_000,
ptyExitDebounceMs: 200,
clientActivityMinGapMs: 1_000,
minProbeGapMs: 100
@@ -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<typeof setTimeout>),
now: () => Date.now()
}
}
@@ -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
}
@@ -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,111 @@ 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, ACCEPTED],
readResolverHealth
})
watch.start()
await drainMicrotasks()
await clock.advance(120_000)
clock.suspend(60 * 60 * 1000)
await clock.advance(0)
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('does not count suspension during an in-flight probe as rejection evidence', async () => {
let resolveProbe!: (outcome: LoginPreflightOutcome) => void
const deferredProbe = new Promise<LoginPreflightOutcome>((resolve) => {
resolveProbe = resolve
})
const probe = vi
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
.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()
})
it('never retires when the session was never conclusively accepted', async () => {
const { watch, clock, onRetire } = createWatch({
outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED]
@@ -183,20 +295,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 +447,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 +457,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)
@@ -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: 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
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,14 +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
ptyExitDebounceMs: number
clientActivityMinGapMs: number
minProbeGapMs: number
}>
clock?: LoginSessionWatchClock
timing?: Partial<LoginSessionWatchTiming>
}
/**
@@ -62,9 +49,10 @@ 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
private readonly ptyExitDebounceMs: number
private readonly clientActivityMinGapMs: number
private readonly minProbeGapMs: number
@@ -73,6 +61,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
@@ -88,20 +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<typeof setTimeout>),
now: () => Date.now()
}
this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS
this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_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 {
@@ -169,9 +154,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 +167,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 +189,7 @@ export class MacosLoginSessionDeathWatch {
}
}
private async runProbe(trigger: string): Promise<void> {
private async runProbe(trigger: string, scheduledAtMs?: number): Promise<void> {
if (this.stopped || this.retired) {
return
}
@@ -209,6 +198,22 @@ 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)
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
})
}
const elapsedSinceProbe =
this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs
if (
@@ -220,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 {
@@ -228,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 })
@@ -246,6 +263,7 @@ export class MacosLoginSessionDeathWatch {
}
this.armed = true
this.consecutiveRejections = 0
this.firstRejectionAtMs = null
this.scheduleNextProbe(this.periodicProbeMs)
return
}
@@ -254,13 +272,27 @@ export class MacosLoginSessionDeathWatch {
this.scheduleNextProbe(this.periodicProbeMs)
return
}
this.firstRejectionAtMs ??= this.clock.now()
this.consecutiveRejections++
this.log.log('login-session-probe-rejected', {
trigger,
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
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(() => {
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown> | 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()
})
})
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown> | 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()
})
})
@@ -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)
+20 -11
View File
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown> | 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)
+58 -19
View File
@@ -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<number, WebContents>
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<RemoteWatcherInstallResult> {
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<void> {
// 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) {
@@ -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]:/)
}
})
})
+147
View File
@@ -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<string, FsChangeEvent>()
const deleteBeforeCreate = new Map<string, FsChangeEvent>()
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<typeof setTimeout> | 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
}
}
}
@@ -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<void> | 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<typeof NodeFs>()
const patched: Record<string, unknown> = { ...actual }
for (const name of Object.keys(actual)) {
const original = (actual as unknown as Record<string, unknown>)[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<typeof NodeFsPromises>()
const patched: Record<string, unknown> = { ...actual }
for (const name of ['stat', 'access', 'rename', 'copyFile', 'rm', 'mkdir', 'open']) {
const fn = (actual as unknown as Record<string, (...args: unknown[]) => unknown>)[name]
patched[name] = async (...args: unknown[]): Promise<unknown> => {
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<void>
flushOrThrow(): void
}
async function createStore(dir: string): Promise<TestStore> {
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<string, string> {
const snapshot: Record<string, string> = {}
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<void> {
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<void>((resolve) => {
releaseRotation = resolve
})
let signalRotation!: () => void
const rotationStarted = new Promise<void>((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([])
})
})
+58 -24
View File
@@ -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<boolean> {
return access(path).then(
() => true,
() => false
)
}
function buildWorkspaceDirHistoryForUpdate(
current: GlobalSettings,
updates: Partial<GlobalSettings>
@@ -2742,6 +2750,8 @@ export class Store {
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | 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<boolean> {
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<void> {
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 {
+488 -4
View File
@@ -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<string, WorktreeMeta> = {
[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()
+76 -6
View File
@@ -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<string>(),
paneKeys: new Set<string>(),
ptyIds: new Set<string>()
}
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<string, RuntimeWorktreePsSummary>,
runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds: Set<string>,
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>,
connectedPtyEvidence: {
tabIds: ReadonlySet<string>
paneKeys: ReadonlySet<string>
ptyIds: ReadonlySet<string>
}
): 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
}
@@ -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()
}
+15 -14
View File
@@ -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<SshResolvedConfig, 'identityFile'> | 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,
+29 -1
View File
@@ -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')
+57 -6
View File
@@ -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<typeof vi.fn>
@@ -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>): 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<void>((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<void>((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)'
+13 -22
View File
@@ -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<SshResolvedConfig, 'proxyUseFdpass' | 'proxyCommand' | 'proxyJump'> | 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'
@@ -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`)
}
@@ -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> = {}): 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<string> {
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)
})
})
+107
View File
@@ -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
}
+1 -17
View File
@@ -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')
})
+118
View File
@@ -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<Buffer | null> {
let handle: Awaited<ReturnType<typeof open>> | 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<IdentityInspection> {
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<SshResolvedConfig, 'identityFile'> | null
): Promise<boolean> {
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
}
+106
View File
@@ -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()
})
})
+39 -7
View File
@@ -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)
}
+33
View File
@@ -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<T>(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', () => {
+16 -1
View File
@@ -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<string, unknown>).preloadURL
webPreferences.nodeIntegration = false
@@ -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 {}
}
}
+20
View File
@@ -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)
})
})
+7
View File
@@ -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 })
+20
View File
@@ -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<string, unknown>): number {
return this.estimateFrameBytes({
jsonrpc: '2.0',
method,
...(params !== undefined ? { params } : {})
})
}
feed(data: Buffer): void {
this.feedForClient(this.primaryClient, data)
}
+189
View File
@@ -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', () => {
+71 -3
View File
@@ -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()) {
@@ -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)
+100
View File
@@ -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<RelayGraceDecisionInput> = {}) {
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 })
})
})
})
+63
View File
@@ -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 }
}
+36 -6
View File
@@ -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 }]
})
}
@@ -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()
}
})
})
+13 -3
View File
@@ -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
@@ -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
+81 -10
View File
@@ -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<void> {
function configureRelayGraceTime(params: Record<string, unknown>): { 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<void> {
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<void> {
}
graceDeadlineAt = null
graceReason = null
graceBranch = null
ptyHandler.cancelGraceTimer()
}
@@ -1116,26 +1137,45 @@ async function main(): Promise<void> {
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<void> {
)
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<void> {
})
.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.
+255
View File
@@ -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<void>
): Promise<void> {
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
@@ -76,6 +76,15 @@ function installAnimationFrameController(): {
}
}
async function flushPortalFramesUntil(
frames: ReturnType<typeof installAnimationFrameController>,
settled: () => boolean
): Promise<void> {
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(<ActivityTerminalSlot />)
})
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')
})
})
@@ -3693,6 +3693,7 @@ function BrowserPagePane({
container,
inputLocked: inputLockedRef.current,
webviewPartition,
allowWindowClose: browserTab.allowWindowClose === true,
resolveContainer: () =>
ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null
})
@@ -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:/)
})
})
@@ -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.
@@ -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
@@ -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<Record<string, DirCache>>
@@ -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<string, DirCache> = {}
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<string, DirCache> = {}
const setDirCache = vi.fn((update: CacheUpdate) => {
cache = typeof update === 'function' ? update(cache) : update
})
let releaseFirstWave!: () => void
const firstWaveGate = new Promise<void>((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<string, DirCache> = {}
const setDirCache = vi.fn((update: CacheUpdate) => {
cache = typeof update === 'function' ? update(cache) : update
})
let releaseFirst!: () => void
const firstGate = new Promise<void>((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)
})
})
@@ -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<SetStateAction<Record<string, DirCache>>>
readDirectory: (dirPath: string) => Promise<FileExplorerDirectoryListing>
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<boolean> {
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<typeof result, { current: true }> =>
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
}
@@ -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)
})
})
@@ -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
}
}
@@ -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<string, DirCache> {
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')
})
})
@@ -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<string, DirCache>,
worktreePath: string,
expanded: Set<string>
): 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'
}
@@ -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'
@@ -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()
})
})
@@ -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<FileExplorerTreeRefreshOutcome>
resolve: (outcome?: FileExplorerTreeRefreshOutcome) => void
} {
let resolve!: (outcome?: FileExplorerTreeRefreshOutcome) => void
const promise = new Promise<FileExplorerTreeRefreshOutcome>((res) => {
resolve = (outcome = 'refreshed') => res(outcome)
})
return { promise, resolve }
}
function setup(
overrides: Partial<Parameters<typeof createFileExplorerWatchRefreshScheduler>[0]> = {}
) {
const { refreshTree: treeImpl, refreshDir: dirImpl, ...rest } = overrides
const refreshTree = vi.fn(
treeImpl ?? (async (): Promise<FileExplorerTreeRefreshOutcome> => '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<string>()
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<typeof setTimeout>) => {
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<FileExplorerTreeRefreshOutcome> => {
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)
})
})
})
@@ -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<typeof setTimeout>
export type FileExplorerWatchRefreshScheduler = {
requestFullRefresh: () => void
requestDirRefresh: (dirPath: string) => void
cancel: () => void
}
export type CreateFileExplorerWatchRefreshSchedulerParams = {
refreshTree: () => Promise<FileExplorerTreeRefreshOutcome>
refreshDir: (dirPath: string) => Promise<void>
/** 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<string, string>()
let firstRequestAt: number | null = null
let timer: SchedulerTimer | null = null
let inFlight: Promise<void> | 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
}
}
}
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
const REFRESH_SPINNER_DELAY_MS = 200
export function useFileExplorerManualRefresh(refreshTree: () => Promise<void>): {
export function useFileExplorerManualRefresh(refreshTree: () => Promise<unknown>): {
isRefreshing: boolean
showRefreshSpinner: boolean
handleRefresh: () => void
@@ -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<Record<string, unknown>>()),
readFileExplorerDirectory: readDirectoryMock
}))
vi.mock('./file-explorer-operation-owner', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<string>()
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<void>((resolve) => {
releaseRoot = resolve
})
readDirectoryMock.mockImplementationOnce(async () => {
await rootGate
return listing()
})
let refresh!: Promise<string>
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)
})
})
@@ -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<string, DirCache>
@@ -30,113 +29,13 @@ type UseFileExplorerTreeResult = {
) => Promise<boolean>
statPath: (path: string) => Promise<{ isDirectory: boolean }>
markPathAsDirectory: (path: string) => void
refreshTree: () => Promise<void>
refreshTree: () => Promise<FileExplorerTreeRefreshOutcome>
refreshDir: (dirPath: string) => Promise<void>
/** 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<SetStateAction<Record<string, DirCache>>>
readDirectory: (dirPath: string) => Promise<FileExplorerDirectoryListing>
}
export async function refreshFileExplorerExpandedDirs({
dirs,
worktreePath,
dirLoadTracker,
setDirCache,
readDirectory
}: RefreshFileExplorerExpandedDirsParams): Promise<boolean> {
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<typeof result, { current: true }> =>
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<string>,
@@ -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<string>())
// 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<FileExplorerTreeRefreshOutcome> => {
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
}
}
@@ -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)
})
})
@@ -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) &&
@@ -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<string>
setSelectedPath: Dispatch<SetStateAction<string | null>>
refreshDir: (dirPath: string) => Promise<void>
refreshTree: () => Promise<void>
refreshTree: () => Promise<FileExplorerTreeRefreshOutcome>
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
}
@@ -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' }
@@ -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
@@ -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'
+1
View File
@@ -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,
+13 -4
View File
@@ -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<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
workspace.worktreeId,
url,
options?.title,
options?.browserRuntimeEnvironmentId
options?.browserRuntimeEnvironmentId,
options?.allowWindowClose
)
set((s) => {
@@ -1169,6 +1177,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
return get().createBrowserPage(workspaceId, pageToRestore.url, {
title: pageToRestore.title,
activate: true,
allowWindowClose: pageToRestore.allowWindowClose,
browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId
})
},
@@ -0,0 +1,2 @@
// Why: this marker carries the CLI's explicit close policy across <webview> attach before the guest exists.
export const BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD = 'file:///__orca_window_close_allowed__'
@@ -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
+2
View File
@@ -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
+1
View File
@@ -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(),
+158 -4
View File
@@ -29,10 +29,11 @@ async function createBrowserTab(
page: Parameters<typeof getActiveWorktreeId>[0],
worktreeId: string,
url?: string,
title = 'New Browser Tab'
title = 'New Browser Tab',
allowWindowClose?: boolean
): Promise<CreatedBrowserTab | null> {
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<void>
}> {
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(`
<!doctype html>
<html>
<head><title>Close link source</title></head>
<body><a id="window-close-link" href="${origin}/window-close" target="_blank">Open close page</a></body>
</html>
`)
return
}
response.end(`
<!doctype html>
<html>
<head><title>Window close repro</title></head>
<body>
<p id="s">Attempting close…</p>
<script>
window.close()
setTimeout(() => {
document.getElementById('s').textContent =
'window.close() was blocked (expected).'
}, 200)
</script>
</body>
</html>
`)
})
await new Promise<void>((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<typeof getActiveWorktreeId>[0],
browserTabId: string
): Promise<string> {
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<void> {
await new Promise<void>((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.
@@ -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<void> {
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<void> {
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<HTMLElement>(
'.xterm-scrollbar.xterm-vertical'
)
const thumb = scrollbar?.querySelector<HTMLElement>('.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<void> {
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<RevealFrame[]> {
return page.evaluate(
(targetTabId) =>
new Promise<RevealFrame[]>((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<HTMLElement>('.xterm-scrollbar.xterm-vertical') ?? null
const thumb = scrollbar?.querySelector<HTMLElement>('.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<HTMLElement>('.xterm-slider')
return Boolean(
thumb && Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2
)
})
).toBe(true)
})
})