From cdfdadf9ead507fe8e77ac1b658a850af37ff0d9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:36:30 -0400 Subject: [PATCH 01/26] fix(runtime): settle tui-idle on hook state for agents whose hooks cover the whole turn (#24388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): install Codex's Interrupt hook so an Esc-cancelled turn settles Codex 0.150+ fires an Interrupt hook when the user presses Esc on an approval prompt or mid-tool, and nothing else. Orca did not install it, so the pane stayed blocked/working until the next prompt. - Add Interrupt to the managed Codex events and label maps, written with Codex's 3s cap (a larger value triggers a startup clamp warning). - Hash the timeout Codex hashes (Interrupt is clamped to [1,3], default 1) so self-computed trust matches Codex; pinned against a real 0.159.3 hash. - Map a root Interrupt to the existing cancelled-turn record (markCodexLeadTurnInterrupted), keeping child work in the fold; a child-scoped Interrupt is ignored. Relayed rows take the same path. * test(runtime): add a readiness census pinning every tui-idle verdict Replays every recorded agent PTY transcript frame by frame through a real runtime pane (agent-known and agent-unknown, clocked and clockless) and a synthetic evidence matrix for all 43 TuiAgents, and compares each verdict and tui-idle wait outcome to committed run-length-encoded baselines. Refs STA-9098 * test(runtime): pin the census quiet probes to literal windows A census that read TUI_IDLE_QUIESCENCE_MS would move with it; fixed 2999/3000 ms reads and a fixed 2000 ms poll step make a changed window show as changed verdicts. Refs STA-9098 * test(runtime): say which census probe writes runtime state Refs STA-9098 * refactor(codex): let the hook builder own Codex's per-event timeout The managed hook's timeout is now Codex's own normalization of the shared budget, and every installer derives its trust entry from the hook it wrote, so no installer repeats the Interrupt special case. Claude-Session: codex-interrupt-hook review * refactor(codex): route Interrupt through the Stop lead update with an outcome Interrupt now writes the lead record through the same setCodexMainAgentTurnState call as Stop, so markCodexLeadTurnInterrupted keeps its original signature. Drops the child-scoped Interrupt guard: Codex never runs Interrupt hooks for subagents and its input schema has no agent_id. Claude-Session: codex-interrupt-hook review * test(runtime): observe the census through settled panes and caller-visible waits - Read each verdict through the runtime's own settle seam (evaluateTuiIdleForLeaf) instead of re-wiring evaluateTuiIdle/leafTuiIdleEvidence/buildTerminalWaitText, so the census is coupled to one runtime method, not to the module STA-9098 rewrites. - Let the runtime finish each chunk (one macrotask turn) before reading. The old read raced work chained on the paint, so 14 frames pinned a microtask-ordering artefact. - Record when a wait settles (@start vs @poll), not just its outcome. - Exit each pane's PTY after reading it so its emulator is freed. - Replace the hand-grouped families, literal fixture list and per-pane split flag with a directory-scanned catalog, one baseline per replayed pane, and size-balanced shards. - Run the synthetic matrix in one file; it takes about 2 s. * test(runtime): cross dialog-versus-ready-screen order with every title in the census matrix Blocked detection is position-ordered (design doc 11.5): the later of a blocker and a ready anchor wins. The matrix now paints a workspace-trust dialog after, and before, each agent's ready screen under every title, so a rule engine that loses that ordering fails per agent. * test(runtime): read the census baseline field without Reflect.get The anti-slop lint rejects Reflect.get on parsed input. * refactor(runtime): read Antigravity, Cline, Prime Agent and Cursor readiness from rule files Adds agent-state-rules/: a zod-validated JSON file per agent, one priority list of screen rules per agent (idle with strength and requiresQuiet, or hold), and text anchors that feed the shared, position-ordered blocked layer every pane reads first. The three screen-ruled agents and Cursor's approval menu and prompt move to data; the Antigravity text scan stays code as a named anchor. Their old code paths are deleted. Every other agent still runs through the existing lanes, unchanged. The readiness census baselines are untouched and pass. Refs STA-9098 * test(runtime): cover the agent state rule engine's schema, priority, rows, anchors and lanes Refs STA-9098 * fix(runtime): refuse rule patterns that repeat an optional or alternating group The load-time regex check only flagged a repeated group whose body held * + or {, so (a?)* and (a|aa)+ passed though both backtrack exponentially. A repeated group's body must now be fixed: no quantifier of any kind and no alternation. The comment states the remaining polynomial gap instead of claiming linearity. * refactor(runtime): give agent state rules and text anchors one when/answer shape Every rule and text anchor is now when (a region and what it must show) plus answer, each a discriminated union, so part (b) adds title, text and status regions and working or blocked answers as new variants instead of new fields. - Cursor's prompt is two anchors answering working and idle; the one-off workingIfAfter and followedBy fields become a general after test. - Anchor literals and the probe banner must be lowercase, since they are matched against the lowercased tail. - screenProbeBanner moves under profile, the place for non-detection facts. - why is required on every rule and anchor. - A blocked anchor must name a lastOf literal, which the prefilter keys on. * docs: point the readiness evidence docs at the agent state rule files * refactor(runtime): read Codex, Claude, OpenCode, Pi, OMP and Gemini readiness from rule files The rule engine gains the regions and answers these agents need, as closed-list entries: - rule regions `title` (the classified title status) and `text` (one of the file's idle text anchors, settled), and a `predicate` form of the screen region for named engine scans; - `withoutClock: skip` for strong quiet rules a clockless pane must not believe; - anchors (renamed from textAnchors) gain a `title` region, and `live` and `hold` answers; - `profile.screenSource` (trusted grid or live screen), and an `unknown-pane` file for panes with no known agent. Codex's header, composer and provisional-startup checks become named predicates referenced from codex.json; its ready header, header and startup hold become shared text anchors. Native idle title markers become shared title anchors; name-only title handling becomes each agent's idle-title rule. The agent-specific branches in terminal-wait-detection.ts and tui-idle-evidence.ts are deleted, and the "later live prompt cancels a blocker" rule now reads only rule-file anchors (plus Muse, which moves in part b2). No behaviour change: the readiness census baselines are untouched and pass. Refs STA-9098 * test(runtime): cover the rule engine's title, text and predicate regions and the bundled anchors Refs STA-9098 * fix(runtime): reject a rule file that repeats an anchor or rule id A text rule names its anchor by id, so a repeated id let a file pass validation and then throw while compiling. Also states that engineVersion bumps once a version ships; version 1 is still being defined. * refactor(runtime): fold the working anchor answer into live The engine treated an anchor's working and live answers identically: both mark a live prompt that cancels an earlier blocker and settles nothing. Cursor's busy prompt now answers live, so anchors have one non-settling prompt answer. Refs STA-9098 * refactor(runtime): read the shared π title anchor from pi.json alone Pi and OMP paint the same `π - ` rest title, and title anchors apply to every pane, so one copy covers both. Refs STA-9098 * refactor(runtime): key every rule file and read the trusted screen from screenSource alone readsTrustedScreen no longer also asks for a screen rule (every trusted file has one, and the schema requires screenSource where it matters), so rule-less files need no filter. A rule's match is a plain boolean, and compileTitleAnchors is module-private. Refs STA-9098 * test(runtime): pin that a clocked Codex pane takes no other agent's ready text No test failed when holdsReadyTextToQuiet was removed; this one does. Refs STA-9098 * fix(agent-hooks): keep an OMP approval wait until omp resolves it omp posts tool_execution_start a few milliseconds after tool_approval_requested, while its Approve/Deny select still holds the human. Both mapped onto the pane row, so the working event overwrote the blocked one and the pane read as busy for the whole prompt. A working event now leaves an OMP approval wait in place; only tool_approval_resolved or a new turn ends it. An ask row is unchanged: its own tool_execution_end ends it. The test replays the order a live omp 17 run posted for a denied bash call. Refs STA-9100 * refactor(runtime): select the fresh hook row on any of a terminal's handles or pane keys selectFreshExplicitAgentStatus matched one handle and one pane key and returned only the mapped status. The row selection now takes sets of handles and pane keys, an optional received-at floor, and returns the row itself, so a reader can see the main agent's own state. The old function keeps its signature and result on top of it. Refs STA-9100 * feat(runtime): let tui-idle read hook state for agents whose hooks cover the whole turn tui-idle read no hook state. Hook state reached readiness only through the ` ready` titles the window writes, so a headless `orca serve` never saw it (#16095), and Codex settled only once its screen had been quiet for three seconds. Rule files gain `profile.hooks: "authoritative" | "identity-only"`, defaulting to identity-only. Codex (with its Interrupt hook), OpenCode, OpenCode 2, Pi and OMP are authoritative. For them a fresh hook-store row decides ahead of every other lane: - the main agent's turn decides (`mainAgent.state` when published), so a subagent's Stop does not end the lead turn: done settles strong, working holds, a permission wait never settles; - the tail's blocked text goes through the existing permission arbiter with the turn as its explicit status, so a denied prompt's dialog left in the tail no longer blocks a turn the hook says ended; - the row joins on every pane key and terminal handle the PTY owns. No row, a stale, restored or other agent's row, a session-start done, and a row from before a PTY respawn all fall back to today's lanes. That keeps startup on the screen and text rules: Codex posts SessionStart only with the first prompt. Claude, Cursor, Gemini and the rest stay identity-only. The readiness census has no hook server, so its frames are unchanged. Refs STA-9100 * docs(agent-status): record readiness as a reader of the hook store Refs STA-9100 * fix(runtime): ignore a hook done older than the latest input Orca wrote A finished turn leaves a fresh `done` row. A caller that sends the next prompt and waits at once could settle on it before the new turn's first hook arrives, so the wait returned while the agent was starting work. Orca's own input writes (terminal send, agent prompts, mailbox pointers) now stamp a per-PTY input clock, and the hook lane reads no `done` received before it; the pane falls back to the screen and text rules until the agent reports again. A `working` row is unaffected. Refs STA-9100 * docs(agent-status): note the input floor on the hook lane's done Refs STA-9100 * fix(runtime): take the hook lane's input floor from the PTY run's input record The hook lane ignored a done older than Orca's latest write to the pane, kept in a new per-PTY map stamped by a wrapper threaded through four write sites. The PTY run register already sits on both write funnels, so it now records the last input (launch writes included, terminal replies not) and the lane reads it. Keys the user types now count too, which closes the restart-in-the-same-shell gap: typing `codex` to relaunch no longer lets the previous process's done read ready while the new one boots. The respawn floor moves from the shared row join into the lane, beside the input floor; the freshest row predates a floor exactly when every row does. * test(runtime): drop runtime hook-lane cases the unit suite already proves Working over a ready title, a permission wait, and an identity-only agent are decided inside evaluateTuiIdle and covered there; the runtime suite keeps the wiring: the join, both floors, Pi's own OSC 133 markers and the arbiter. * fix(runtime): record a PTY's last input even when main adopted it without a spawn commit A materialized pane re-adopted by the renderer returns before the spawn-commit site, so it had no run record and its input never moved the hook lane's floor. The last input now lives beside the run records: any PTY's input counts, and a new process's commit still clears it. * fix(runtime): keep a running process's input time when main reattaches or adopts it A reattach or adoption commit without an incarnation id cleared the PTY's last-input time, so a prompt sent just before an SSH adoption was forgotten and the hook lane could accept the previous turn's done as ready. Only a new process (or a reattach naming a different incarnation) now starts clean; the first-input fact follows the same rule. * docs(runtime): say why a lead turn that ended reads ready while a subagent runs * fix(runtime): refuse uppercase contains terms in text anchors, which read the lowercased tail A text anchor's after and lines tests run on the lowercased tail, so an uppercase contains term loaded and then never matched. Build the text test schema from the literal it accepts and give anchors the lowercase one. Also drop a probe-banner early return that no bundled catalog reaches. * refactor(runtime): state Codex's provisional startup and title anchors as plain rules The provisional-startup hold becomes a lastOf anchor with an all/none test, so its TypeScript scan goes. Title anchors drop their status field (every caller already gates on an idle title), and withoutClock keeps only the value a rule can set. * fix(runtime): leave Codex readiness to its title and screen rules Codex before its Interrupt hook posts nothing for an Esc mid-turn, so its hook row stays working and a hook-authoritative tui-idle wait hangs until the row goes stale. Current Codex already settles fast through its ready title. Co-Authored-By: Claude --------- Co-authored-by: Claude --- docs/reference/agent-status-store.md | 36 +++ .../agent-state-rules-engine.ts | 11 +- .../agent-state-rules-schema.ts | 10 + src/main/runtime/agent-state-rules/omp.json | 1 + .../runtime/agent-state-rules/opencode.json | 1 + .../runtime/agent-state-rules/opencode2.json | 1 + src/main/runtime/agent-state-rules/pi.json | 1 + ...-authoritative-terminal-wait-permission.ts | 49 ++- src/main/runtime/orca-runtime-runtime-id.ts | 1 + .../runtime-hook-agent-row-selection.ts | 88 ++--- src/main/runtime/terminal-run-facts.test.ts | 66 +++- src/main/runtime/terminal-run-facts.ts | 33 +- src/main/runtime/tui-idle-evidence.ts | 26 +- .../tui-idle-hook-lane-runtime.test.ts | 197 ++++++++++++ src/main/runtime/tui-idle-hook-lane.test.ts | 304 ++++++++++++++++++ src/main/runtime/tui-idle-hook-lane.ts | 110 +++++++ ...ok-listener-omp-approval-ownership.test.ts | 38 +++ .../providers/pi-family-events.ts | 32 +- 18 files changed, 945 insertions(+), 60 deletions(-) create mode 100644 src/main/runtime/tui-idle-hook-lane-runtime.test.ts create mode 100644 src/main/runtime/tui-idle-hook-lane.test.ts create mode 100644 src/main/runtime/tui-idle-hook-lane.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b962df41edc..e60e683c738 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -441,6 +441,42 @@ the renderer, `runtime-worktree-status-projection.ts` in main, and PR 3 moves the rollup and the decay into `src/shared` and makes all three call it. +## Readiness reads the store + +`terminal wait --for tui-idle` is a reader too. Before STA-9100 hook state +reached it only through the ` ready` titles the window writes, so a +headless `orca serve` never saw it (#16095). Now an agent whose rule file says +`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) has its +fresh row read straight from the store, through the same +`selectFreshExplicitAgentStatusRow` join prompt-receipt verification uses +(`src/main/runtime/tui-idle-hook-lane.ts`): + +- the main agent's turn, not the combined row, decides: `mainAgent.state` when + published, so a subagent's Stop does not end the lead turn. `done` settles + the wait, `working` holds it, and a permission wait never settles. The tail's + blocked text goes through the existing permission arbiter with the turn as + its explicit status, so a denied prompt's dialog left in the tail no longer + blocks a turn the hook says ended; +- the row joins on any pane key or terminal handle the PTY owns; a pane neither + reaches, a stale or restored row, a session-start `done`, a row from before + the PTY respawned, and a `done` received before the pane's latest input all + leave the decision to the screen and text rules, which is also how startup + readiness works before an agent's first hook. The input is the PTY run's + `lastInputAt` (`terminal-run-facts.ts`), which both write funnels record, so + a key the user typed counts like a prompt Orca sent: the next turn's first + hook may still be in flight, and an agent restarted in the same shell has + not posted one. A shell command marker is no process boundary: Pi paints + OSC 133 zones itself; +- every other agent stays `identity-only`: Claude sends no event when an + approval is denied or Esc stops a tool, so its row can sit at `waiting` or + `working` forever, and the rules keep deciding. Codex is identity-only too: + before its `Interrupt` hook an Esc mid-turn leaves the row `working`, and an + older TUI can hand its hooks to a newer shared app server, so no version + check tells which Codex posts it. Current Codex settles fast anyway, since + `Interrupt` drives its `Codex ready` title. + +The titles stay for display; remote clients read them. + ## What does not change - The hook scripts, the OSC 9999 wire format, and the relay protocol. diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts index 795d68c019e..5ba0b853b60 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts @@ -7,6 +7,7 @@ import { type AgentStateRuleAnswer, type AgentStateRuleCondition, type AgentStateRulesFile, + type HookAuthority, type NamedScreenPredicate } from './agent-state-rules-schema' import { compileTextAnchor } from './agent-state-text-anchors' @@ -102,14 +103,15 @@ export function compileAgentRules(file: AgentStateRulesFile): CompiledRule[] { type RulesKey = TuiAgent | typeof UNKNOWN_PANE_RULES_ID -type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean } +type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean; hooks: HookAuthority } const FILES_BY_KEY: ReadonlyMap = new Map( BUNDLED_AGENT_STATE_RULE_FILES.map((file) => [ file.id, { rules: compileAgentRules(file), - readsTrustedScreen: file.profile?.screenSource === 'trusted' + readsTrustedScreen: file.profile?.screenSource === 'trusted', + hooks: file.profile?.hooks ?? 'identity-only' } ]) ) @@ -128,6 +130,11 @@ export function readsTrustedScreen(agent: TuiAgent | null | undefined): boolean return compiledFileFor(agent)?.readsTrustedScreen ?? false } +/** Whether a fresh hook row for the agent's main turn decides readiness ahead of its rules. */ +export function hooksAreAuthoritative(agent: TuiAgent | null | undefined): boolean { + return compiledFileFor(agent)?.hooks === 'authoritative' +} + function someRule( agent: TuiAgent | null | undefined, test: (rule: CompiledRule) => boolean diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts index de2dbae4cc5..f5aafc2db3a 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts @@ -218,9 +218,18 @@ const AnchorSchema = z 'a title anchor answers idle' ) +/** + * How far readiness may trust the agent's hooks. `authoritative`: they report every way the main + * agent's turn ends (done, cancelled, an approval granted or denied), so a fresh hook row decides + * ahead of the rules. `identity-only` (the default): some end sends no event (Claude's denied + * approval and Esc mid-tool), so hooks only name the agent and the rules decide. + */ +const HOOK_AUTHORITIES = ['authoritative', 'identity-only'] as const + /** Facts about the agent that are not detection rules. */ const ProfileSchema = z .object({ + hooks: z.enum(HOOK_AUTHORITIES).optional(), /** Text whose presence in a pane's tail makes a tui-idle wait read its visible screen once. */ screenProbeBanner: TailLiteral.optional(), /** The screen the rules read: the PTY's own grid, trusted only while the PTY still has that @@ -279,4 +288,5 @@ export type TitleAnchorCondition = z.infer export type NamedTextAnchor = (typeof NAMED_TEXT_ANCHORS)[number] export type NamedScreenPredicate = (typeof NAMED_SCREEN_PREDICATES)[number] export type NamedTitlePredicate = (typeof NAMED_TITLE_PREDICATES)[number] +export type HookAuthority = (typeof HOOK_AUTHORITIES)[number] export type AgentStateRulesFile = z.infer diff --git a/src/main/runtime/agent-state-rules/omp.json b/src/main/runtime/agent-state-rules/omp.json index a0311486cb7..08d06e198bc 100644 --- a/src/main/runtime/agent-state-rules/omp.json +++ b/src/main/runtime/agent-state-rules/omp.json @@ -1,6 +1,7 @@ { "id": "omp", "engineVersion": 1, + "profile": { "hooks": "authoritative" }, "anchors": [], "rules": [ { diff --git a/src/main/runtime/agent-state-rules/opencode.json b/src/main/runtime/agent-state-rules/opencode.json index 47316f368c9..8e89dbab62e 100644 --- a/src/main/runtime/agent-state-rules/opencode.json +++ b/src/main/runtime/agent-state-rules/opencode.json @@ -1,6 +1,7 @@ { "id": "opencode", "engineVersion": 1, + "profile": { "hooks": "authoritative" }, "anchors": [ { "id": "native_title", diff --git a/src/main/runtime/agent-state-rules/opencode2.json b/src/main/runtime/agent-state-rules/opencode2.json index d1d543d08fe..6588c13ce39 100644 --- a/src/main/runtime/agent-state-rules/opencode2.json +++ b/src/main/runtime/agent-state-rules/opencode2.json @@ -1,6 +1,7 @@ { "id": "opencode2", "engineVersion": 1, + "profile": { "hooks": "authoritative" }, "anchors": [], "rules": [ { diff --git a/src/main/runtime/agent-state-rules/pi.json b/src/main/runtime/agent-state-rules/pi.json index 67b9eb3221a..3e5067f6f24 100644 --- a/src/main/runtime/agent-state-rules/pi.json +++ b/src/main/runtime/agent-state-rules/pi.json @@ -1,6 +1,7 @@ { "id": "pi", "engineVersion": 1, + "profile": { "hooks": "authoritative" }, "anchors": [ { "id": "idle_title", diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index 04cb91e0281..a86c95d8742 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -5,7 +5,7 @@ import type { AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' import { isOpenCodeNativeTitle } from '../../shared/agent-detection' -import type { AgentStatusEntry } from '../../shared/agent-status-types' +import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' @@ -14,6 +14,7 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentPromptActivity } from './agent-prompt-submission-verification' +import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation { protected resolveAuthoritativeTerminalWaitPermission( @@ -49,6 +50,52 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt ? blockedByWaitText : null } + /** The pane's main-agent turn from the hook server's store, for tui-idle's hook lane. */ + protected readTuiIdleHookTurnForPty(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null { + const pty = this.ptysById.get(ptyId) + const hookRows = this.getAgentStatusSnapshotFn?.() + if (!pty || !hookRows) { + return null + } + const handles = this.getExistingTerminalHandlesForPtyId(ptyId) + const paneKeys = this.collectPaneKeysForPty(ptyId) + if (pty.paneKey) { + paneKeys.add(pty.paneKey) + } + return readTuiIdleHookTurn({ + agent, + handles, + paneKeys, + hookRows, + respawnedAt: this.agentPromptExplicitStatusFloorByPtyId.get(ptyId), + lastInputAt: this.terminalRunFacts.readLastInputAt(ptyId), + resolveBlockedText: (state, row) => + this.resolveTuiIdleHookBlockedText(ptyId, handles, state, row) + }) + } + + private resolveTuiIdleHookBlockedText( + ptyId: string, + handles: readonly string[], + state: 'done' | 'working' | 'permission', + row: AgentStatusIpcPayload + ): RuntimeTerminalWaitBlockedReason | null { + const handle = this.handleByPtyId.get(ptyId) ?? handles[0] + if (!handle) { + return null + } + try { + return this.resolveAuthoritativeTerminalWaitPermission( + this.getTerminalAgentStatusSnapshot(handle, ptyId), + { status: state === 'done' ? 'idle' : state, updatedAt: row.receivedAt }, + this.agentPromptLifecycleByPtyId.get(ptyId) + ) + } catch { + // A handle that no longer targets this PTY has no text to judge. + return null + } + } + renewMobileAgentStatusFromPtyTitle( status: AgentStatusEntry | null, pty: RuntimePtyWorktreeRecord | null, diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 3a16c95a553..dbdec24d0fc 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -348,6 +348,7 @@ export class OrcaRuntimeWithRuntimeId { getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), getFirstPartyAgentStatus: (ptyId) => (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, + getHookTurn: (ptyId, agent) => this.readTuiIdleHookTurnForPty(ptyId, agent), readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId), readScreenRuledLines: (ptyId) => this.readScreenRuledLines(ptyId) } diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts index 67c1b698a1d..fcea4898b07 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -28,6 +28,43 @@ function isLiveObservation(row: AgentStatusIpcPayload): boolean { return row.restoredUnconfirmed !== true && row.providerSessionOnly !== true } +type HookRowJoin = { + handles: Iterable + paneKeys: Iterable + hookRows: readonly AgentStatusIpcPayload[] +} + +function isPermissionState(state: AgentStatusEntry['state']): boolean { + return mapExplicitAgentStateToRuntimeTerminalStatus(state) === 'permission' +} + +/** The freshest live hook row for a terminal, joined on any of its handles or pane keys. */ +export function selectFreshExplicitAgentStatusRow(args: HookRowJoin): AgentStatusIpcPayload | null { + const now = Date.now() + const handles = new Set(args.handles) + const paneKeys = new Set(args.paneKeys) + let best: AgentStatusIpcPayload | null = null + for (const row of args.hookRows) { + if (!(row.terminalHandle && handles.has(row.terminalHandle)) && !paneKeys.has(row.paneKey)) { + continue + } + if (!row.state || !isLiveObservation(row) || typeof row.receivedAt !== 'number') { + continue + } + if (now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS) { + continue + } + if ( + !best || + row.receivedAt > best.receivedAt || + (row.receivedAt === best.receivedAt && isPermissionState(row.state)) + ) { + best = row + } + } + return best +} + /** The freshest explicit state for a terminal, matched on its handle or its pane key. */ export function selectFreshExplicitAgentStatus(args: { handle: string @@ -38,49 +75,16 @@ export function selectFreshExplicitAgentStatus(args: { updatedAt: number stateStartedAt: number } | null { - const now = Date.now() - let bestStatus: NonNullable | null = null - let bestUpdatedAt = -1 - let bestStateStartedAt = -1 - const consider = ( - state: AgentStatusEntry['state'] | undefined, - updatedAt: number | null | undefined, - evidenceObservedAt: number | null | undefined, - restoredUnconfirmed = false, - providerSessionOnly = false, - stateStartedAt?: number | null - ): void => { - if (!state || restoredUnconfirmed || providerSessionOnly || typeof updatedAt !== 'number') { - return - } - if (now - (evidenceObservedAt ?? updatedAt) > AGENT_STATUS_STALE_AFTER_MS) { - return - } - const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) - if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { - bestStatus = status - bestUpdatedAt = updatedAt - bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt - } - } - for (const row of args.hookRows) { - if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { - continue - } - consider( - row.state, - row.receivedAt, - row.evidenceObservedAt, - row.restoredUnconfirmed, - row.providerSessionOnly, - row.stateStartedAt - ) - } - return bestStatus + const row = selectFreshExplicitAgentStatusRow({ + handles: [args.handle], + paneKeys: args.paneKey ? [args.paneKey] : [], + hookRows: args.hookRows + }) + return row ? { - status: bestStatus, - updatedAt: bestUpdatedAt, - stateStartedAt: bestStateStartedAt + status: mapExplicitAgentStateToRuntimeTerminalStatus(row.state), + updatedAt: row.receivedAt, + stateStartedAt: typeof row.stateStartedAt === 'number' ? row.stateStartedAt : row.receivedAt } : null } diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index e1982ebc968..85460b9fc27 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -39,14 +39,49 @@ describe('terminal run facts', () => { expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false) }) - it('starts clean when a commit carries no incarnation to tell it from a new process', () => { + it('starts clean when a new process commits without an incarnation', () => { const facts = new TerminalRunFactsRegister() facts.recordSpawnCommit({ id: 'pty-1' }) facts.recordInput('pty-1', 'driving', 'ls\r', 100) - facts.recordSpawnCommit({ id: 'pty-1', isReattach: true }) + facts.recordSpawnCommit({ id: 'pty-1' }) - expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: null }) + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(facts.readLastInputAt('pty-1')).toBeNull() + }) + + it.each([ + ['a reattach', { isReattach: true }], + ['an adoption', { agentSessionEnsure: { disposition: 'adopted' } }] + ])('keeps the input of the running process through %s without an incarnation', (_l, commit) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1' }) + facts.recordInput('pty-1', 'driving', 'next prompt\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', ...commit }) + + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: 100 }) + expect(facts.readLastInputAt('pty-1')).toBe(100) + }) + + it('keeps input recorded before main adopted the process with its first commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pty-1', 'driving', 'next prompt\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.readLastInputAt('pty-1')).toBe(100) + }) + + it('starts clean when a reattach names a different process than the one recorded', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2', isReattach: true }) + + expect(facts.read('pty-1', 'inc-2')).toEqual({ freshSpawn: false, firstUserInputAt: null }) + expect(facts.readLastInputAt('pty-1')).toBeNull() }) it.each([ @@ -62,4 +97,29 @@ describe('terminal run facts', () => { expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBeNull() }) + + it('records the last input, a launch write included, but no terminal reply', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + facts.recordInput('pty-1', 'launch', 'next task\r', 200) + facts.recordInput('pty-1', 'query-reply', 'answer', 300) + facts.recordInput('pty-1', 'driving', '\x1b[I', 400) + + expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBe(100) + expect(facts.readLastInputAt('pty-1')).toBe(200) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + expect(facts.readLastInputAt('pty-1')).toBe(200) + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2' }) + expect(facts.readLastInputAt('pty-1')).toBeNull() + }) + + it('records the last input on a PTY main adopted without a spawn commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pty-1', 'driving', 'next task\r', 100) + + expect(facts.readLastInputAt('pty-1')).toBe(100) + expect(facts.read('pty-1', null).firstUserInputAt).toBeNull() + }) }) diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index 1fb3e411b6c..b20f64985e4 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -40,22 +40,27 @@ type TerminalRunRecord = { /** Main's per-process facts about one PTY run, keyed by the incarnation they describe. */ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() + // Why apart from the run record: input must count on a PTY main adopted without a commit. + private readonly lastInputAtByPtyId = new Map() - /** Once per process: a re-registration of the same incarnation keeps its facts. Without an - * incarnation a commit cannot be told from a new process, so it starts clean. */ + /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a + * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null - if ( - incarnationId !== null && - this.runsByPtyId.get(commit.id)?.incarnationId === incarnationId - ) { + const previous = this.runsByPtyId.get(commit.id) + if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } const origin = spawnCommitBindingOrigin(commit, expectedSourceBinding) + const sameProcess = + origin === 'reattach' && (incarnationId === null || !previous?.incarnationId) + if (!sameProcess) { + this.lastInputAtByPtyId.delete(commit.id) + } this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: null + firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null }) } @@ -63,15 +68,22 @@ export class TerminalRunFactsRegister { * such as `exit` can end the process before the write returns. The payload check backs up a * writer that labels a reply or focus report as driving. */ recordInput(ptyId: string, inputKind: TerminalInputKind, data: string, now = Date.now()): void { - if (inputKind !== 'driving') { + if (inputKind === 'query-reply' || isUntypedTerminalInput(data)) { return } + this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) - if (run && run.firstUserInputAt === null && !isUntypedTerminalInput(data)) { - run.firstUserInputAt = now + if (run && inputKind === 'driving') { + run.firstUserInputAt ??= now } } + /** When input other than a terminal reply last reached the PTY's current process, launch writes + * included; null if none has. */ + readLastInputAt(ptyId: string): number | null { + return this.lastInputAtByPtyId.get(ptyId) ?? null + } + /** A run main never saw committed reads as not fresh, which keeps today's close-on-exit. */ read(ptyId: string, incarnationId: string | null | undefined): TerminalRunFacts { const run = this.runsByPtyId.get(ptyId) @@ -86,5 +98,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) + this.lastInputAtByPtyId.delete(ptyId) } } diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 1a9fa52bad4..ce51d3104c5 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -23,6 +23,7 @@ import { readsTrustedScreen, type AgentStateVerdict } from './agent-state-rules/agent-state-rules-engine' +import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' /** * Ranking the evidence that a `tui-idle` wait may settle on. @@ -33,7 +34,10 @@ import { * stale spinner (#1437) — so a busy Codex/Devin pane is routinely titled idle, and * accepting it satisfied a wait in ~0s mid-turn (#6011). * - * 0. BLOCKED — the tail shows a prompt waiting on the user. + * 0. HOOKS — for an agent whose hooks are authoritative (agent-state-rules/ profile), a fresh + * hook row for the main agent's turn: done, working, or a permission wait, with the tail's + * blocked text judged by the permission arbiter against it (tui-idle-hook-lane.ts). + * 0b. BLOCKED — otherwise, the tail shows a prompt waiting on the user. * 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. * 1b. QUIET READY SCREEN — Muse titles no rest signal, and agents whose rules @@ -209,6 +213,8 @@ export type TuiIdleEvaluationInput = { readAgentRuleVerdict: () => AgentStateVerdict | null agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus + /** Tier 0: the hook server's fresh row for the pane, read only for an authoritative agent. */ + readHookTurn?: () => TuiIdleHookTurn | null quiescenceMs: number } @@ -256,6 +262,10 @@ export function hasQuietReadyScreen( /** The one place the tiers are combined; every settle site branches only on the verdict. */ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { + const hookVerdict = input.readHookTurn ? evaluateHookTurn(input.agent, input.readHookTurn) : null + if (hookVerdict) { + return hookVerdict + } const blockedReason = input.readTailBlockedReason() if (blockedReason) { return { kind: 'blocked', reason: blockedReason } @@ -346,6 +356,8 @@ export type TuiIdleEvidenceSource = { getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus + /** The hook server's fresh row for the pane's main agent; absent on a host with no store. */ + getHookTurn?(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null readScreenLines(ptyId: string | null | undefined): readonly string[] | null /** The painted rows on the PTY's own grid, which only agents whose rules read the trusted * screen use. Absent, they have no trustworthy screen. */ @@ -380,6 +392,16 @@ function readAgentRuleVerdict( }) } +function hookTurnReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): (() => TuiIdleHookTurn | null) | undefined { + return source.getHookTurn && agent && ptyId + ? () => source.getHookTurn?.(ptyId, agent) ?? null + : undefined +} + function lazyWaitText(readWaitText: () => string): () => string { let waitText: string | null = null return () => (waitText ??= readWaitText()) @@ -403,6 +425,7 @@ export function leafTuiIdleEvidence( readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), agent, firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), + readHookTurn: hookTurnReader(source, agent, leaf.ptyId), quiescenceMs: source.quiescenceMs } } @@ -425,6 +448,7 @@ export function ptyTuiIdleEvidence( readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), agent, firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), + readHookTurn: hookTurnReader(source, agent, pty.ptyId), quiescenceMs: source.quiescenceMs } } diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts new file mode 100644 index 00000000000..312bd3b8e67 --- /dev/null +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import type { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// The harness pane's identity (agent-transcript-pane-test-harness.ts). +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const OTHER_PANE_KEY = makePaneKey('tab-9', '99999999-9999-4999-8999-999999999999') +// A Pi turn in progress: no ready sign the other lanes could settle on. +const BUSY_SCREEN = '⠋ Working...\r\n' +// A Codex turn in progress, which Codex's screen rules read as busy. +const CODEX_BUSY_SCREEN = '• Working (4s • esc to interrupt)\r\n' +// OpenCode 1.18's permission dialog, as the line tail keeps it after the prompt is answered. +const OPENCODE_PERMISSION_DIALOG = [ + '△ Permission required', + '# Shell command', + '$ echo hi', + ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', + '' +].join('\r\n') +// Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. +const WAIT_MS = 150 + +function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'pi', + receivedAt: now, + stateStartedAt: now, + ...overrides + } +} + +async function waitOutcome(options: { + rows: (handle: string) => AgentStatusIpcPayload[] + launchAgent?: TuiAgent + data?: string + afterCreate?: (runtime: OrcaRuntimeService, handle: string) => unknown +}): Promise { + let handle = '' + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ?? 'pi', + data: options.data ?? BUSY_SCREEN, + launchAgent: options.launchAgent ?? 'pi' + }, + { getAgentStatusSnapshot: () => options.rows(handle) } + ) + handle = pane.handle + await options.afterCreate?.(pane.runtime, pane.handle) + try { + const result = await pane.runtime.waitForTerminal(pane.handle, { + condition: 'tui-idle', + timeoutMs: WAIT_MS + }) + return result.blockedReason ? `blocked:${result.blockedReason}` : 'ready' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('tui-idle hook lane through the runtime', () => { + it('settles a mid-turn screen on the hook row alone, with no ready title (headless serve)', async () => { + expect(await waitOutcome({ rows: () => [row()] })).toBe('ready') + }) + + it('without a row, the same pane does not settle', async () => { + expect(await waitOutcome({ rows: () => [] })).toBe('timeout') + }) + + it('joins a row on the terminal handle when its pane key is not the pane', async () => { + expect( + await waitOutcome({ + rows: (handle) => [row({ paneKey: OTHER_PANE_KEY, terminalHandle: handle })] + }) + ).toBe('ready') + }) + + it('falls back to the other lanes for a row no pane key or handle joins', async () => { + expect(await waitOutcome({ rows: () => [row({ paneKey: OTHER_PANE_KEY })] })).toBe('timeout') + }) + + it('ignores the row the PTY id held before a respawn', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect(await waitOutcome({ rows })).toBe('ready') + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => + runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { + value: 0, + generation: 'reset' + }) + }) + ).toBe('timeout') + }) + + // Both write funnels record input (terminal-run-facts-input.test.ts), the user's keys included: + // typing `pi` to restart the agent in the same shell must not read the old process's done. + it.each([ + ['a prompt Orca sent', 'next task\r'], + ['the user restarting the agent in the same shell', 'pi\r'] + ])('reads no done from before %s', async (_label, input) => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { + runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) + } + expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') + expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') + }) + + it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => { + runtime.terminalRunFacts.recordInput( + TRANSCRIPT_PANE_PTY_ID, + 'driving', + 'next\r', + before + 1 + ) + runtime.noteTerminalSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + agentSessionEnsure: { disposition: 'adopted' } + }) + } + }) + ).toBe('timeout') + }) + + // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. + it('keeps the row while the agent paints its own shell-integration markers', async () => { + expect( + await waitOutcome({ + rows: () => [row({ receivedAt: Date.now() - 1000 })], + data: `\x1b]133;A\x07OK\x1b]133;C\x07${BUSY_SCREEN}` + }) + ).toBe('ready') + }) + + // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so its row can stay + // working; its title and screen rules decide instead. + it('leaves Codex to its screen rules, whatever its hook row says', async () => { + const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } + expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe( + 'timeout' + ) + }) + + it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_PERMISSION_DIALOG } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe( + 'blocked:agent-interactive-prompt' + ) + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) +}) diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts new file mode 100644 index 00000000000..87683f266cf --- /dev/null +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusIpcPayload +} from '../../shared/agent-status-types' +import { hooksAreAuthoritative } from './agent-state-rules/agent-state-rules-engine' +import { parseAgentStateRuleFiles } from './agent-state-rules/agent-state-rules-catalog' +import { hookLeadTurnState, readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' +import { + evaluateTuiIdle, + type TuiIdleEvaluationInput, + type TuiIdleEvidenceRecord +} from './tui-idle-evidence' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const OTHER_PANE_KEY = 'tab-2:22222222-2222-4222-8222-222222222222' +const HANDLE = 'term_hook_lane' +const QUIESCENCE_MS = 3000 +const DONE: TuiIdleHookTurn = { state: 'done', blockedReason: null } +const WORKING: TuiIdleHookTurn = { state: 'working', blockedReason: null } + +function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'pi', + receivedAt: now, + stateStartedAt: now, + ...overrides + } +} + +describe('hookLeadTurnState', () => { + it('reads the main agent, so a subagent finishing or running does not decide the lead turn', () => { + // A child still runs after the lead ended: the combined row works, the lead is done. + expect( + hookLeadTurnState({ state: 'working', mainAgent: { state: 'done', stateStartedAt: 1 } }) + ).toBe('done') + // A child's Stop while the lead still works leaves the lead working. + expect( + hookLeadTurnState({ state: 'done', mainAgent: { state: 'working', stateStartedAt: 1 } }) + ).toBe('working') + }) + + it('reads a cancelled turn as ended', () => { + expect( + hookLeadTurnState({ + state: 'done', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } + }) + ).toBe('done') + }) + + it("blocks on any agent's permission wait, a child's included", () => { + expect(hookLeadTurnState({ state: 'waiting' })).toBe('permission') + expect(hookLeadTurnState({ state: 'blocked' })).toBe('permission') + expect( + hookLeadTurnState({ state: 'waiting', mainAgent: { state: 'working', stateStartedAt: 1 } }) + ).toBe('permission') + }) + + it('falls back to the row state for agents that publish no main agent', () => { + expect(hookLeadTurnState({ state: 'done' })).toBe('done') + expect(hookLeadTurnState({ state: 'working' })).toBe('working') + }) + + it('leaves a session start to the startup rules', () => { + expect(hookLeadTurnState({ state: 'done', sessionBoundary: true })).toBeNull() + }) +}) + +describe('readTuiIdleHookTurn', () => { + const base = { + agent: 'pi' as const, + handles: [HANDLE], + paneKeys: [PANE_KEY], + resolveBlockedText: () => null + } + + it('reads a fresh row joined on the pane key', () => { + expect(readTuiIdleHookTurn({ ...base, hookRows: [row()] })).toEqual(DONE) + }) + + it('joins a row on the terminal handle when the pane key differs', () => { + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ paneKey: OTHER_PANE_KEY, terminalHandle: HANDLE, state: 'working' })] + }) + ).toEqual(WORKING) + }) + + it('has no answer for a pane no row joins', () => { + expect( + readTuiIdleHookTurn({ + ...base, + handles: [], + paneKeys: [], + hookRows: [row({ terminalHandle: HANDLE })] + }) + ).toBeNull() + expect( + readTuiIdleHookTurn({ ...base, hookRows: [row({ paneKey: OTHER_PANE_KEY })] }) + ).toBeNull() + }) + + it('refuses stale, restored and identity-only rows', () => { + const staleAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + for (const stale of [ + row({ receivedAt: staleAt }), + row({ evidenceObservedAt: staleAt }), + row({ restoredUnconfirmed: true }), + row({ providerSessionOnly: true }) + ]) { + expect(readTuiIdleHookTurn({ ...base, hookRows: [stale] })).toBeNull() + } + }) + + it("refuses a row another agent wrote, and one from the pane's previous process", () => { + expect(readTuiIdleHookTurn({ ...base, hookRows: [row({ agentType: 'claude' })] })).toBeNull() + const receivedAt = Date.now() - 1000 + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt })], + respawnedAt: receivedAt + 1 + }) + ).toBeNull() + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt })], + respawnedAt: receivedAt + }) + ).toEqual(DONE) + }) + + it('reads no done from before the latest input, whose turn may not have reported yet', () => { + const receivedAt = Date.now() - 1000 + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt })], + lastInputAt: receivedAt + 1 + }) + ).toBeNull() + expect( + readTuiIdleHookTurn({ ...base, hookRows: [row({ receivedAt })], lastInputAt: receivedAt }) + ).toEqual(DONE) + // A working row keeps the pane busy whatever its age against the input. + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt, state: 'working' })], + lastInputAt: receivedAt + 1 + }) + ).toEqual(WORKING) + }) + + it('takes the newest joined row', () => { + const now = Date.now() + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [ + row({ state: 'done', receivedAt: now }), + row({ state: 'working', receivedAt: now - 10, terminalHandle: HANDLE }) + ] + }) + ).toEqual(DONE) + }) + + it("hands the permission arbiter the lead turn as the pane's explicit status", () => { + const resolveBlockedText = vi.fn(() => 'agent-approval-prompt' as const) + const waiting = row({ state: 'waiting' }) + expect(readTuiIdleHookTurn({ ...base, resolveBlockedText, hookRows: [waiting] })).toEqual({ + state: 'permission', + blockedReason: 'agent-approval-prompt' + }) + expect(resolveBlockedText).toHaveBeenCalledWith('permission', waiting) + const childRunning = row({ state: 'working', mainAgent: { state: 'done', stateStartedAt: 1 } }) + readTuiIdleHookTurn({ ...base, resolveBlockedText, hookRows: [childRunning] }) + expect(resolveBlockedText).toHaveBeenLastCalledWith('done', childRunning) + }) +}) + +describe('profile.hooks', () => { + it('marks the agents whose hooks report every turn end as authoritative', () => { + for (const agent of ['opencode', 'opencode2', 'pi', 'omp'] as const) { + expect(hooksAreAuthoritative(agent)).toBe(true) + } + for (const agent of ['codex', 'claude', 'cursor', 'gemini', 'grok', null] as const) { + expect(hooksAreAuthoritative(agent)).toBe(false) + } + }) + + it('rejects an authority outside the closed list', () => { + expect(() => + parseAgentStateRuleFiles([ + { id: 'pi', engineVersion: 1, profile: { hooks: 'partial' }, anchors: [], rules: [] } + ]) + ).toThrow(/hooks/) + }) +}) + +function record(overrides: Partial = {}): TuiIdleEvidenceRecord { + return { lastAgentStatus: null, lastOutputAt: Date.now(), lastOscTitle: null, ...overrides } +} + +function input(overrides: Partial = {}): TuiIdleEvaluationInput { + return { + record: record(), + readTailBlockedReason: () => null, + readPositiveBodyEvidence: () => false, + readQuietReadyBodyEvidence: () => false, + readAgentRuleVerdict: () => null, + agent: 'pi', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS, + ...overrides + } +} + +describe('evaluateTuiIdle hook lane', () => { + it('settles at once on a done turn, with no title and a streaming pane (headless)', () => { + expect(evaluateTuiIdle(input({ readHookTurn: () => DONE }))).toEqual({ + kind: 'ready-strong' + }) + }) + + it('holds a working turn over ready text and an idle title', () => { + expect( + evaluateTuiIdle( + input({ + record: record({ lastAgentStatus: 'idle', lastOscTitle: 'Pi ready' }), + readPositiveBodyEvidence: () => true, + readHookTurn: () => WORKING + }) + ) + ).toEqual({ kind: 'working' }) + }) + + it('blocks with the arbiter reason, and leaves an unconfirmed wait to the screen read', () => { + expect( + evaluateTuiIdle( + input({ + readHookTurn: () => ({ state: 'permission', blockedReason: 'agent-approval-prompt' }) + }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-approval-prompt' }) + expect( + evaluateTuiIdle( + input({ + readPositiveBodyEvidence: () => true, + readHookTurn: () => ({ state: 'permission', blockedReason: null }) + }) + ) + ).toEqual({ kind: 'pending', quietForeground: 'closed' }) + }) + + it('lets the arbiter, not the raw tail, judge blocked text once a fresh row exists', () => { + // A denied prompt's dialog text lingers in the tail after the hook says the turn ended. + expect( + evaluateTuiIdle( + input({ readTailBlockedReason: () => 'agent-interactive-prompt', readHookTurn: () => DONE }) + ) + ).toEqual({ kind: 'ready-strong' }) + expect( + evaluateTuiIdle( + input({ + readTailBlockedReason: () => 'agent-interactive-prompt', + readHookTurn: () => ({ state: 'done', blockedReason: 'agent-trust-workspace' }) + }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-trust-workspace' }) + // With no fresh row, the raw tail still blocks. + expect( + evaluateTuiIdle( + input({ readTailBlockedReason: () => 'agent-trust-workspace', readHookTurn: () => null }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-trust-workspace' }) + }) + + it('falls back to the other lanes with no fresh row (startup, before the first prompt)', () => { + expect( + evaluateTuiIdle(input({ readPositiveBodyEvidence: () => true, readHookTurn: () => null })) + ).toEqual({ kind: 'ready-strong' }) + expect( + evaluateTuiIdle(input({ readPositiveBodyEvidence: () => false, readHookTurn: () => null })) + ).toEqual({ kind: 'pending', quietForeground: 'closed' }) + }) + + it.each(['claude', 'codex'] as const)('never reads hooks for identity-only %s', (agent) => { + const readHookTurn = vi.fn(() => ({ state: 'done' as const, blockedReason: null })) + expect(evaluateTuiIdle(input({ agent, readHookTurn }))).toEqual({ + kind: 'pending', + quietForeground: 'closed' + }) + expect(readHookTurn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts new file mode 100644 index 00000000000..04bfaa1bbdd --- /dev/null +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -0,0 +1,110 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' +import type { TuiAgent } from '../../shared/tui-agent' +import { hooksAreAuthoritative } from './agent-state-rules/agent-state-rules-engine' +import { selectFreshExplicitAgentStatusRow } from './runtime-hook-agent-row-selection' + +type HookTurnState = 'done' | 'working' | 'permission' + +/** + * The main agent's turn as the hook server's store last saw it for one pane, and the permission + * arbiter's verdict on the pane's blocked text with that turn as its explicit status. + */ +export type TuiIdleHookTurn = { + state: HookTurnState + blockedReason: RuntimeTerminalWaitBlockedReason | null +} + +/** + * Reads the main agent's turn off its row. Why the main agent's own state: the row's combined + * state folds child work in, and a subagent finishing must not end the lead turn (#6011); a + * child's permission wait still blocks, since its prompt is on the pane. A lead turn that ended + * reads done even while a subagent runs: its composer takes input then, as the screen rules read it. + */ +export function hookLeadTurnState( + row: Pick +): HookTurnState | null { + // Why no answer: a session start is not a turn end, and some agents post it before their + // composer accepts input, so startup readiness stays with the screen and text rules. + if (row.state === 'done' && row.sessionBoundary === true) { + return null + } + if (row.state === 'waiting' || row.state === 'blocked') { + return 'permission' + } + const lead = row.mainAgent?.state ?? row.state + return lead === 'done' || lead === 'working' ? lead : 'permission' +} + +export type TuiIdleHookTurnRead = { + agent: TuiAgent + handles: Iterable + paneKeys: Iterable + hookRows: readonly AgentStatusIpcPayload[] + /** When the PTY respawned: every row from before it is the previous process's. */ + respawnedAt?: number + /** When input last reached the pane, typed or sent: a `done` from before it cannot speak for + * the turn that input may have started (or the agent it restarted), whose first hook can still + * be in flight. */ + lastInputAt?: number | null + /** The existing permission arbiter, given the turn as the pane's explicit status. */ + resolveBlockedText( + status: HookTurnState, + row: AgentStatusIpcPayload + ): RuntimeTerminalWaitBlockedReason | null +} + +/** + * The pane's freshest hook row, joined on its pane keys and terminal handles. A pane neither + * reaches (a PTY created with no pane key whose agent never posted under one) has no answer, and + * neither does a row another agent or an earlier process wrote: the caller's other lanes decide. + */ +export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn | null { + const row = selectFreshExplicitAgentStatusRow(read) + if (!row || row.agentType !== read.agent || row.receivedAt < (read.respawnedAt ?? -1)) { + return null + } + const state = hookLeadTurnState(row) + if (state === null || (state === 'done' && row.receivedAt < (read.lastInputAt ?? -1))) { + return null + } + return { state, blockedReason: read.resolveBlockedText(state, row) } +} + +/** The tui-idle verdicts the hook lane can reach (a subset of `TuiIdleVerdict`). */ +export type TuiIdleHookVerdict = + | { kind: 'ready-strong' } + | { kind: 'working' } + | { kind: 'blocked'; reason: RuntimeTerminalWaitBlockedReason } + | { kind: 'pending'; quietForeground: 'closed' } + +/** + * Tier 0 of tui-idle-evidence.ts for an agent whose hooks are authoritative. Why ahead of every + * rule: its hooks report each way a turn ends, and they reach a headless host, where the + * ` ready` titles the window writes never appear (#16095). Why the arbiter judges the blocked + * text: a denied prompt's dialog lingers in the line tail after the hook says the turn moved on. + * No fresh row (startup, before the first prompt, or an unjoinable pane) leaves the other tiers. + */ +export function evaluateHookTurn( + agent: TuiAgent | null | undefined, + readHookTurn: () => TuiIdleHookTurn | null +): TuiIdleHookVerdict | null { + if (!hooksAreAuthoritative(agent)) { + return null + } + const turn = readHookTurn() + if (turn?.blockedReason) { + return { kind: 'blocked', reason: turn.blockedReason } + } + switch (turn?.state) { + case undefined: + return null + case 'done': + return { kind: 'ready-strong' } + case 'working': + return { kind: 'working' } + case 'permission': + // Why pending: the arbiter saw no prompt in the tail, so the poll's screen read decides. + return { kind: 'pending', quietForeground: 'closed' } + } +} diff --git a/src/shared/agent-hook-listener-omp-approval-ownership.test.ts b/src/shared/agent-hook-listener-omp-approval-ownership.test.ts index 1e8850404ed..d8de670b224 100644 --- a/src/shared/agent-hook-listener-omp-approval-ownership.test.ts +++ b/src/shared/agent-hook-listener-omp-approval-ownership.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createHookListenerState, + seedLegacyAgentStatusForTests, type HookListenerState } from './agent-hook-listener/listener-state' import { normalizeHookPayload } from './agent-hook-listener' @@ -89,6 +90,43 @@ describe('OMP approval ownership', () => { ).toMatchObject({ state: 'working', toolName: 'bash' }) }) + /** Normalizes the post and admits its row, as the hook server does, so the next post sees it. */ + function deliver(payload: Record): string { + const event = post('omp', payload) + if (event) { + seedLegacyAgentStatusForTests(state, event) + } + return state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state ?? 'none' + } + + // The order omp 17 posted for a denied bash call, captured off a live run's extension events. + it('keeps the approval wait through the tool_execution_start omp posts right after it', () => { + expect(deliver({ hook_event_name: 'tool_call', tool_name: 'bash' })).toBe('working') + expect( + deliver({ + hook_event_name: 'tool_approval_requested', + tool_name: 'bash', + approval_mode: 'always-ask' + }) + ).toBe('blocked') + expect(deliver({ hook_event_name: 'tool_execution_start', tool_name: 'bash' })).toBe('blocked') + expect( + deliver({ hook_event_name: 'tool_approval_resolved', tool_name: 'bash', approved: false }) + ).toBe('working') + expect(deliver({ hook_event_name: 'tool_execution_end', tool_name: 'bash' })).toBe('working') + expect(deliver({ hook_event_name: 'agent_end' })).toBe('done') + }) + + it('ends a held approval wait when a new turn starts', () => { + deliver({ hook_event_name: 'tool_approval_requested', tool_name: 'bash' }) + expect(deliver({ hook_event_name: 'agent_start' })).toBe('working') + }) + + it('lets an ask wait end on its own tool_execution_end', () => { + expect(deliver({ hook_event_name: 'tool_execution_start', tool_name: 'ask' })).toBe('blocked') + expect(deliver({ hook_event_name: 'tool_execution_end', tool_name: 'ask' })).toBe('working') + }) + // Pi and prime-agent share this normalizer but have no approval lifecycle, so the field must not // give their panes a status row. it.each(['always-ask', 'write', 'yolo'])( diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts index c8704419163..9786f09fb56 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -81,7 +81,10 @@ export function normalizePiCompatibleEvent( stateName = hookPayload.is_idle === true ? 'done' : 'working' } - if (!stateName) { + if ( + !stateName || + (stateName === 'working' && holdsOmpApproval(state, agentType, eventName, paneKey)) + ) { return null } @@ -107,3 +110,30 @@ export function normalizePiCompatibleEvent( lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } + +const OMP_APPROVAL_RELEASE_EVENTS: ReadonlySet = new Set([ + 'tool_approval_resolved', + 'before_agent_start', + 'agent_start' +]) + +/** + * Whether an OMP approval wait must survive this working event. omp posts `tool_execution_start` + * right after `tool_approval_requested`, while its Approve/Deny select still holds the human, so + * only the resolution (or a new turn) ends the wait. An `ask` row is its own tool's wait, which + * `tool_execution_end` ends. + */ +function holdsOmpApproval( + state: HookListenerState, + agentType: 'pi' | 'omp' | 'prime-agent', + eventName: unknown, + paneKey: string +): boolean { + if (agentType !== 'omp' || OMP_APPROVAL_RELEASE_EVENTS.has(eventName)) { + return false + } + const previous = state.lastStatusByPaneKey.get(paneKey)?.payload + return ( + previous?.agentType === 'omp' && previous.state === 'blocked' && previous.toolName !== 'ask' + ) +} From 11b1c8f35364aea12bee2a72b2bb81f1400901a9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:49:11 -0700 Subject: [PATCH 02/26] test(e2e): dismiss the browser tour before starting screenshot markup (#24534) --- tests/e2e/paired-client-hosted-browser-markup.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/e2e/paired-client-hosted-browser-markup.spec.ts b/tests/e2e/paired-client-hosted-browser-markup.spec.ts index 43572cbff75..519d7530b71 100644 --- a/tests/e2e/paired-client-hosted-browser-markup.spec.ts +++ b/tests/e2e/paired-client-hosted-browser-markup.spec.ts @@ -35,7 +35,13 @@ test('draws and copies a screenshot from a client-hosted browser without replaci const target = { urlPrefix: fixture.origin, remotePageId: browser.remotePageId } await waitForRenderedClientWebview(client.page, target, 'client-hosted fixture never rendered') - await client.page.getByRole('button', { name: 'Got it', exact: true }).click() + // The markup hint also says "Got it" and can appear before the browser tour. + const browserTour = client.page.getByRole('dialog', { + name: 'This page renders on your desktop', + exact: true + }) + await browserTour.getByRole('button', { name: 'Got it', exact: true }).click() + await expect(browserTour).toBeHidden() await testInfo.attach('client-hosted-toolbar', { body: await client.page.screenshot({ path: testInfo.outputPath('toolbar.png') }), contentType: 'image/png' From 026b8378a443d37f33b6d58e40966dea6094e628 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:03:08 -0700 Subject: [PATCH 03/26] test(wire): make release compatibility probes deterministic (#24538) --- ...-session-stop-event-downgrade.unit.test.ts | 3 ++- ...ss-version-agent-session-wire.unit.test.ts | 5 +++- .../release-checkout.unit.test.ts | 24 +++++++++++++++---- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 20b64c7f6db..af26da9c687 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -54,6 +54,7 @@ type OldReplay = { truncateFrom?: number } +// Both downgrade probes load real old builds, including cold extraction and transforms. test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() @@ -147,7 +148,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol await journals.closeAll() rmSync(directory, { recursive: true, force: true }) } -}) +}, 120_000) type OlderJournal = { isReadOnly: boolean diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 110fc8e489a..1980ed95c55 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -296,7 +296,10 @@ describe('cross-version structured agent sessions', () => { error: { message: expect.stringContaining('structured_agent_session_unsupported') } }) if (baseline.methodNames.includes('agentSession.createSupport')) { - expect(replies[0]?.error).toEqual((await createSupport(baseline, released()))[0]?.error) + // Older clients read the existing refusal fields and ignore additive error metadata. + expect(replies[0]).toMatchObject({ + error: (await createSupport(baseline, released()))[0]?.error + }) } }) diff --git a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts index 106e2ea778e..b722d006a00 100644 --- a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts +++ b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, @@ -25,6 +26,7 @@ import { type ReleaseCheckout } from './release-checkout' const temporaryRoots: string[] = [] +const temporaryRefs: { ref: string; commit: string }[] = [] const COMPRESSED_LOCK_OPTIONS: CheckoutLockOptions = { realpath: false, @@ -255,6 +257,9 @@ async function runContentionPhase( } afterEach(() => { + for (const { ref, commit } of temporaryRefs.splice(0)) { + git(['update-ref', '-d', ref, commit]) + } for (const root of temporaryRoots.splice(0)) { rmSync(root, { recursive: true, force: true }) } @@ -285,6 +290,7 @@ describe('release checkout materialization', () => { expect(relative(cacheRoot, checkouts[0]!.root)).not.toMatch(/^\.\./) }) + // Cold extraction and transforms need the cross-version suite's startup budget. it('loads a baseline module whose source imports another checkout-root file', async () => { const cacheRoot = temporaryCacheRoot() const checkout = await materializeReleaseCheckout('v1.4.190', { cacheRoot }) @@ -292,12 +298,21 @@ describe('release checkout materialization', () => { expect(protocol.REMOTE_SERVER_UPDATE_CAPABILITY).toBe('updater.remote-control.v1') expect(relative(cacheRoot, checkout.root)).not.toMatch(/^\.\./) - }) + }, 180_000) it('keeps an import live while another colliding release label materializes', async () => { - const merge = git(['rev-list', '--merges', '-1', 'HEAD']) - const firstRef = `${merge}~2` - const secondRef = `${merge}^2` + // Squash-merged history has no merge parents; create two distinct refs with colliding labels. + const scope = `refs/orca-checkout-test/${randomUUID()}` + const firstRef = `${scope}/release` + const secondRef = `${scope}_release` + for (const [ref, revision] of [ + [firstRef, 'HEAD'], + [secondRef, 'v1.4.190'] + ]) { + const commit = git(['rev-parse', `${revision}^{commit}`]) + git(['update-ref', ref, commit, '0'.repeat(40)]) + temporaryRefs.push({ ref, commit }) + } expect(git(['rev-parse', `${firstRef}^{commit}`])).not.toBe( git(['rev-parse', `${secondRef}^{commit}`]) ) @@ -328,6 +343,7 @@ describe('release checkout materialization', () => { } await expect(loading).resolves.toMatchObject({ loaded: 'first-release' }) + expect(first.label).toBe(second.label) expect(first.root).not.toBe(second.root) }) From 783101b3045848d5f9b5eeefe4a699487310521e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 1 Oct 2026 23:03:13 -0700 Subject: [PATCH 04/26] feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519) Read-only export of a direct-SSH target's catalog and dormant state into the signed T6-7 manifest: repositories, folder workspaces and their project groups, worktree metadata and lineage, sparse presets, retired worktree names, the workspace session with bounded scrollback snapshots, automations, and client routing. Reads go through the profile-state Store via a read-only OrcadSourceExportPersistence domain; nothing retires the source. Adds the export-aware migration preflight on top of T6-5's dependents census, a resumable snapshot transfer driver with injected destination operations, and destination-side chunk staging keyed to a caller-supplied staged manifest. Lands the P7/P9 holds: session-owner projection hooks, syncDirectoryDurablySync and the durable-write mode, scrollback path and stored-bytes exports, retained refs, and dormant-tab buffer preservation. Inert until T6-10. Co-authored-by: m4air --- src/main/durable-file-write.ts | 13 +- .../profile-session-owner-transfer.ts | 28 ++ src/main/orcad-migration-export-holds.test.ts | 152 +++++++++ .../orcad/orcad-migration-manifest-digest.ts | 18 + .../session-snapshot-operations.ts | 1 + .../loading-store/store-domain-composition.ts | 12 +- .../loading-store/store-runtime-state.ts | 2 + .../loading-store/terminal-session-cleanup.ts | 6 +- .../workspace-session-snapshot-publication.ts | 20 +- .../orcad-destination-catalog-projection.ts | 9 + ...orcad-scrollback-snapshot-transfer.test.ts | 231 +++++++++++++ .../orcad-scrollback-snapshot-transfer.ts | 302 +++++++++++++++++ .../orcad-source-automation-state.ts | 187 ++++++++++ .../orcad-source-catalog.ts | 57 ++++ .../orcad-source-client-browser-intents.ts | 73 ++++ .../orcad-source-client-state.test.ts | 282 +++++++++++++++ .../orcad-source-client-state.ts | 258 ++++++++++++++ .../orcad-source-dependency-census.test.ts | 320 ++++++++++++++++++ .../orcad-source-dependency-census.ts | 257 ++++++++++++++ .../orcad-source-dormant-state.ts | 273 +++++++++++++++ .../orcad-source-export.test.ts | 127 +++++++ .../orcad-source-export.ts | 128 +++++++ .../orcad-source-retired-worktree-names.ts | 81 +++++ .../orcad-source-scope.test.ts | 58 ++++ .../orcad-source-scope.ts | 60 ++++ .../orcad-source-scrollback-state.test.ts | 39 +++ .../orcad-source-scrollback-state.ts | 158 +++++++++ .../orcad-source-session-dependencies.ts | 145 ++++++++ ...ad-source-workspace-session-eligibility.ts | 245 ++++++++++++++ ...rcad-source-workspace-session-fragments.ts | 113 +++++++ .../orcad-source-workspace-session-layout.ts | 64 ++++ .../orcad-source-workspace-session.ts | 115 +++++++ .../orcad-source-worktree-metadata.ts | 122 +++++++ .../orcad-migration-manifest-export.test.ts | 168 +++++++++ .../ssh/orcad-migration-manifest-export.ts | 77 +++++ ...cad-migration-snapshot-coordinator.test.ts | 171 ++++++++++ .../orcad-migration-snapshot-coordinator.ts | 124 +++++++ src/main/ssh/ssh-target-orcad-claims.ts | 12 +- src/main/ssh/ssh-target-orcad-dependents.ts | 44 ++- .../ssh/ssh-target-orcad-preflight.test.ts | 159 +++++++++ src/main/ssh/ssh-target-orcad-preflight.ts | 79 +++++ ...nal-scrollback-snapshot-async-migration.ts | 5 +- src/main/terminal-scrollback-snapshots.ts | 37 +- src/main/worktree-retirement-namespace.ts | 2 +- ...workspace-session-terminal-buffers.test.ts | 36 +- .../workspace-session-terminal-buffers.ts | 15 +- 46 files changed, 4855 insertions(+), 30 deletions(-) create mode 100644 src/main/orcad-migration-export-holds.test.ts create mode 100644 src/main/orcad/orcad-migration-manifest-digest.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts create mode 100644 src/main/ssh/orcad-migration-manifest-export.test.ts create mode 100644 src/main/ssh/orcad-migration-manifest-export.ts create mode 100644 src/main/ssh/orcad-migration-snapshot-coordinator.test.ts create mode 100644 src/main/ssh/orcad-migration-snapshot-coordinator.ts create mode 100644 src/main/ssh/ssh-target-orcad-preflight.test.ts create mode 100644 src/main/ssh/ssh-target-orcad-preflight.ts diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 05e849327b7..e573b4ecd7f 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -29,7 +29,8 @@ async function syncDirectory(directory: string): Promise { } } -function syncDirectorySync(directory: string): void { +/** Sync variant of the best-effort directory fsync, for callers that publish by rename or link. */ +export function syncDirectoryDurablySync(directory: string): void { let fd: number | null = null try { fd = openSync(directory, 'r') @@ -50,7 +51,7 @@ function syncDirectorySync(directory: string): void { /** Rename an already-fsynced file and make the containing directory durable. */ export function renameDurableSync(tmpPath: string, finalPath: string): void { renameFileWithWindowsRetry(tmpPath, finalPath) - syncDirectorySync(dirname(finalPath)) + syncDirectoryDurablySync(dirname(finalPath)) } /** Publish an already-fsynced file without replacing a concurrently created destination. */ @@ -58,7 +59,7 @@ export function publishFileDurableSync(tmpPath: string, finalPath: string): bool if (!publishFileWithoutOverwrite(tmpPath, finalPath)) { return false } - syncDirectorySync(dirname(finalPath)) + syncDirectoryDurablySync(dirname(finalPath)) rmSync(tmpPath) return true } @@ -209,12 +210,14 @@ export async function removeStaleDurableWriteTempFiles( export function writeFileDurableSync( tmpPath: string, finalPath: string, - payload: string | Uint8Array + payload: string | Uint8Array, + /** Creation mode for a new file, e.g. 0o600 for state other users must not read. */ + mode?: number ): void { let renamed = false try { // A Uint8Array payload is written verbatim; a string still defaults to UTF-8. - writeFileSync(tmpPath, payload) + writeFileSync(tmpPath, payload, mode === undefined ? undefined : { mode }) const fd = openSync(tmpPath, 'r+') try { fsyncSync(fd) diff --git a/src/main/orca-profiles/profile-session-owner-transfer.ts b/src/main/orca-profiles/profile-session-owner-transfer.ts index 040cf921f35..ada6d099c79 100644 --- a/src/main/orca-profiles/profile-session-owner-transfer.ts +++ b/src/main/orca-profiles/profile-session-owner-transfer.ts @@ -1,4 +1,5 @@ import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types' import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' import type { Tab, TabGroup } from '../../shared/tab-types' @@ -11,9 +12,24 @@ import { isWorkspaceKey } from '../../shared/workspace-scope' import { SESSION_FIELDS_COPIED_BY_OWNER_KEY } from './profile-project-session-field-disposition' import { mapMarkdownFrontmatterVisible } from './profile-session-markdown-transfer' +export { + buildMarkdownFrontmatterIdMap, + markdownFileIdCandidates +} from './profile-session-markdown-transfer' + export type SessionOwnerProjection = { mapOwnerKey: (ownerKey: string) => string | null mapWorktreeId: (worktreeId: string) => string + /** Keeps a sleeping agent's resume record when it can still resume after the transfer. */ + projectSleepingAgentSession?: ( + record: SleepingAgentSessionRecord + ) => SleepingAgentSessionRecord | null + /** Projects source-partition focus scalars when the selected entities are dormant. */ + projectSessionFocus?: (args: { + source: WorkspaceSessionState + transferred: WorkspaceSessionState + terminalTabIds: ReadonlySet + }) => void } export function extractSessionOwnersForTransfer( @@ -121,6 +137,18 @@ export function extractSessionOwnersForTransfer( : [] ) ) + projection.projectSessionFocus?.({ source, transferred, terminalTabIds }) + if (projection.projectSleepingAgentSession) { + const sleepingAgentSessionsByPaneKey = Object.fromEntries( + Object.entries(source.sleepingAgentSessionsByPaneKey ?? {}).flatMap(([paneKey, record]) => { + const projected = projection.projectSleepingAgentSession?.(record) + return projected ? [[paneKey, projected] as const] : [] + }) + ) + if (Object.keys(sleepingAgentSessionsByPaneKey).length > 0) { + transferred.sleepingAgentSessionsByPaneKey = sleepingAgentSessionsByPaneKey + } + } transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown ?.filter((worktreeId) => projection.mapOwnerKey(worktreeId) !== null) .map(projection.mapWorktreeId) diff --git a/src/main/orcad-migration-export-holds.test.ts b/src/main/orcad-migration-export-holds.test.ts new file mode 100644 index 00000000000..53c5b0e1ee2 --- /dev/null +++ b/src/main/orcad-migration-export-holds.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, rmSync, statSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../shared/constants' +import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' +import { writeFileDurableSync } from './durable-file-write' +import { + getTerminalScrollbackSnapshotPath, + readTerminalScrollbackStoredBytesSync, + writeTerminalScrollbackSnapshotSync +} from './terminal-scrollback-snapshots' +import { deleteRemovedTerminalScrollbackSnapshots } from './persistence/loading-store/terminal-session-cleanup' +import { deleteRemovedTerminalScrollbackSnapshotsAsync } from './terminal-scrollback-snapshot-async-migration' +import { extractSessionOwnersForTransfer } from './orca-profiles/profile-session-owner-transfer' +import type { SleepingAgentSessionRecord } from '../shared/agent-session-resume' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function storage() { + const root = mkdtempSync(join(tmpdir(), 'orcad-export-holds-')) + roots.push(root) + return { root, storage: { snapshotRoot: join(root, 'terminal-scrollback') } } +} + +function sessionWithRef(ref: string | null): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: ref + ? { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + scrollbackRefsByLeafId: { 'leaf-1': ref } + } + } + : {} + } +} + +describe('scrollback store reads and retention for migration export', () => { + it('reads stored bytes for a ref and none for an unknown one', () => { + const { storage: store } = storage() + const ref = writeTerminalScrollbackSnapshotSync({ + tabId: 'tab-1', + leafId: 'leaf-1', + buffer: 'saved output', + storage: store + }) + expect(ref).not.toBeNull() + expect(readTerminalScrollbackStoredBytesSync(ref ?? '', store)?.toString('utf8')).toBe( + 'saved output' + ) + expect(readTerminalScrollbackStoredBytesSync(`v1-${'0'.repeat(32)}`, store)).toBeNull() + }) + + it.each(['sync', 'async'] as const)( + 'keeps a %s-removed snapshot a pending export still reads', + async (mode) => { + const { storage: store } = storage() + const ref = + writeTerminalScrollbackSnapshotSync({ + tabId: 'tab-1', + leafId: 'leaf-1', + buffer: 'retained', + storage: store + }) ?? '' + const path = getTerminalScrollbackSnapshotPath(ref, store) ?? '' + const retained = new Set([ref]) + if (mode === 'sync') { + deleteRemovedTerminalScrollbackSnapshots( + sessionWithRef(ref), + sessionWithRef(null), + store, + retained + ) + } else { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + sessionWithRef(ref), + sessionWithRef(null), + store, + retained + ) + } + expect(readFileSync(path, 'utf8')).toBe('retained') + deleteRemovedTerminalScrollbackSnapshots(sessionWithRef(ref), sessionWithRef(null), store) + expect(() => statSync(path)).toThrow() + } + ) +}) + +describe.skipIf(process.platform === 'win32')('durable writes with a creation mode', () => { + it('creates the file with the requested mode', () => { + const { root } = storage() + const finalPath = join(root, 'state.json') + writeFileDurableSync(`${finalPath}.tmp`, finalPath, '{}', 0o600) + expect(statSync(finalPath).mode & 0o777).toBe(0o600) + }) +}) + +describe('session owner projection hooks', () => { + function sleeping(paneKey: string): SleepingAgentSessionRecord { + return { + paneKey, + worktreeId: 'repo-1::/srv/app', + agent: 'claude', + providerSession: { key: 'session_id', id: paneKey }, + prompt: 'resume me', + state: 'done', + capturedAt: 1, + updatedAt: 1 + } + } + + it('lets a transfer keep resumable sleeping agents and project focus scalars', () => { + const source: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': sleeping('tab-1:leaf-1'), + 'tab-2:leaf-1': sleeping('tab-2:leaf-1') + } + } + const projectSessionFocus = vi.fn() + const transferred = extractSessionOwnersForTransfer(source, { + mapOwnerKey: (ownerKey) => ownerKey, + mapWorktreeId: (id) => id, + projectSleepingAgentSession: (record) => (record.paneKey === 'tab-1:leaf-1' ? record : null), + projectSessionFocus + }) + expect(Object.keys(transferred.sleepingAgentSessionsByPaneKey ?? {})).toEqual(['tab-1:leaf-1']) + expect(projectSessionFocus).toHaveBeenCalledWith( + expect.objectContaining({ source, transferred }) + ) + }) + + it('drops sleeping agents when the projection does not opt in', () => { + const transferred = extractSessionOwnersForTransfer( + { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { 'tab-1:leaf-1': sleeping('tab-1:leaf-1') } + }, + { mapOwnerKey: (ownerKey) => ownerKey, mapWorktreeId: (id) => id } + ) + expect(transferred.sleepingAgentSessionsByPaneKey).toBeUndefined() + }) +}) diff --git a/src/main/orcad/orcad-migration-manifest-digest.ts b/src/main/orcad/orcad-migration-manifest-digest.ts new file mode 100644 index 00000000000..f9031ceffc7 --- /dev/null +++ b/src/main/orcad/orcad-migration-manifest-digest.ts @@ -0,0 +1,18 @@ +import { createHash } from 'node:crypto' +import { + orcadMigrationManifestHashInput, + type OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' + +export function computeOrcadMigrationManifestSha256( + manifest: Omit +): string { + return createHash('sha256').update(orcadMigrationManifestHashInput(manifest)).digest('hex') +} + +export function assertOrcadMigrationManifestDigest(manifest: OrcadMigrationManifest): void { + const { manifestSha256, ...unsigned } = manifest + if (computeOrcadMigrationManifestSha256(unsigned) !== manifestSha256) { + throw new Error('orcad_migration_manifest_digest_mismatch') + } +} diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 9c3cf23be7b..a5a2d557c47 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -24,6 +24,7 @@ type SessionSnapshotOperationsRuntime = Pick< | 'quitFlushStarted' | 'state' | 'terminalScrollbackSnapshotStorage' + | 'retainedScrollbackRefsByMigrationId' | 'writesFrozen' > diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 70f22e90c5c..437dced8838 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -63,6 +63,10 @@ import { SshLeaseRecoveryOperations, installSshLeaseRecoveryOperationsContext } from './ssh-lease-recovery-operations' +import { + OrcadSourceExportPersistence, + installOrcadSourceExportPersistenceContext +} from '../migrating-orcad-catalog/orcad-source-export' export type StoreDomainOperations = WriteSchedulingOperations & PrimaryStateWriteOperations & @@ -79,6 +83,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & SshProfileOperations & RetiredWorktreeNamePersistence & SshLeaseRecoveryOperations & + OrcadSourceExportPersistence & WriteFlushBarrierOperations export type StoreDomains = { @@ -103,6 +108,7 @@ export type StoreDomains = { sshProfiles: SshProfileOperations retiredWorktreeNames: RetiredWorktreeNamePersistence sshLeases: SshLeaseRecoveryOperations + orcadSourceExport: OrcadSourceExportPersistence } export const STORE_DOMAIN_OPERATION_CLASSES = [ @@ -121,6 +127,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ SshProfileOperations, RetiredWorktreeNamePersistence, SshLeaseRecoveryOperations, + OrcadSourceExportPersistence, WriteFlushBarrierOperations ] as const @@ -140,6 +147,7 @@ export function installStoreDomainContexts(target: Store, domains: StoreDomains) installSshProfileOperationsContext(target, domains.sshProfiles) installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) + installOrcadSourceExportPersistenceContext(target, domains.orcadSourceExport) installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) } @@ -196,6 +204,8 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { ptyBindings, sshProfiles, retiredWorktreeNames, - sshLeases + sshLeases, + // Read-only: holds the runtime state and nothing that writes. + orcadSourceExport: new OrcadSourceExportPersistence(runtime) } } diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index b29f49f95f9..65ba17d74fc 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -44,6 +44,8 @@ export class StoreRuntimeState { automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage + /** Scrollback refs each in-flight migration export still reads, keyed by migration id. */ + readonly retainedScrollbackRefsByMigrationId = new Map>() writeTimer: ReturnType | null = null pendingWrite: Promise | null = null pendingSnapshotFileWork: Promise | null = null diff --git a/src/main/persistence/loading-store/terminal-session-cleanup.ts b/src/main/persistence/loading-store/terminal-session-cleanup.ts index dede6bf8747..7c9b3b10b11 100644 --- a/src/main/persistence/loading-store/terminal-session-cleanup.ts +++ b/src/main/persistence/loading-store/terminal-session-cleanup.ts @@ -27,14 +27,16 @@ export function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSess export function deleteRemovedTerminalScrollbackSnapshots( prior: WorkspaceSessionState | undefined, next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage + storage?: TerminalScrollbackSnapshotStorage, + /** Refs a pending migration export still reads; they outlive the session row. */ + retainedRefs: ReadonlySet = new Set() ): void { if (!prior) { return } const nextRefs = collectTerminalScrollbackSnapshotRefs(next) for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { - if (!nextRefs.has(ref)) { + if (!nextRefs.has(ref) && !retainedRefs.has(ref)) { deleteTerminalScrollbackSnapshotSync(ref, storage) } } diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 96834ffbcf9..178f1d833ff 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -104,7 +104,8 @@ export function setLocalWorkspaceSession( deleteRemovedTerminalScrollbackSnapshots( prior, session, - context.runtime.terminalScrollbackSnapshotStorage + context.runtime.terminalScrollbackSnapshotStorage, + retainedScrollbackRefs(context.runtime) ) } context.runtime.state.workspaceSession = session @@ -140,7 +141,8 @@ export function enqueueTerminalScrollbackSnapshotWork( await deleteRemovedTerminalScrollbackSnapshotsAsync( prior, context.runtime.state.workspaceSession, - context.runtime.terminalScrollbackSnapshotStorage + context.runtime.terminalScrollbackSnapshotStorage, + retainedScrollbackRefs(context.runtime) ) } return @@ -159,14 +161,16 @@ export function enqueueTerminalScrollbackSnapshotWork( await deleteRemovedTerminalScrollbackSnapshotsAsync( migrated, current, - context.runtime.terminalScrollbackSnapshotStorage + context.runtime.terminalScrollbackSnapshotStorage, + retainedScrollbackRefs(context.runtime) ) } if (current) { await deleteRemovedTerminalScrollbackSnapshotsAsync( prior, current, - context.runtime.terminalScrollbackSnapshotStorage + context.runtime.terminalScrollbackSnapshotStorage, + retainedScrollbackRefs(context.runtime) ) } }) @@ -180,3 +184,11 @@ export function enqueueTerminalScrollbackSnapshotWork( }) context.runtime.pendingSnapshotFileWork = work } + +function retainedScrollbackRefs(runtime: { + retainedScrollbackRefsByMigrationId: ReadonlyMap> +}): ReadonlySet { + return new Set( + [...runtime.retainedScrollbackRefsByMigrationId.values()].flatMap((refs) => [...refs]) + ) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts new file mode 100644 index 00000000000..9e7811199ec --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts @@ -0,0 +1,9 @@ +/** How a source catalog row looks once a local orcad owns it: no SSH connection or host. */ +import type { Repo } from '../../../shared/repo-types' + +export function toOrcadDestinationRepository(source: Repo): Repo { + const destination = structuredClone(source) + delete destination.connectionId + delete destination.executionHostId + return destination +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts new file mode 100644 index 00000000000..8e3efdeefe6 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts @@ -0,0 +1,231 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationManifest +} from '../../../shared/orcad-migration-manifest' +import type { + OrcadMigrationSnapshotChunkRequest, + OrcadMigrationTerminalScrollbackSnapshot +} from '../../../shared/orcad-migration-scrollback' +import { + getTerminalScrollbackSnapshotPath, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' +import { + abortOrcadMigrationSnapshots, + assertOrcadMigrationSnapshotsReady, + commitOrcadMigrationSnapshots, + inspectOrcadMigrationSnapshotUploads, + pruneOrcadMigrationSnapshotStaging, + stageOrcadMigrationSnapshotChunk +} from './orcad-scrollback-snapshot-transfer' + +let root: string +let storage: TerminalScrollbackSnapshotStorage + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orcad-migration-snapshots-')) + storage = { snapshotRoot: join(root, 'terminal-scrollback') } +}) + +afterEach(() => rmSync(root, { recursive: true, force: true })) + +describe('orcad scrollback snapshot transfer', () => { + it('resumes from staged byte length and accepts only exact idempotent retries', () => { + const bytes = Buffer.from('first line\nmultibyte: 🐋\nlast line', 'utf8') + const descriptor = snapshot('1', 'tab-1', 'leaf-1', bytes) + const manifest = migrationManifest('resume', [descriptor]) + const state = stagedState(manifest) + const first = bytes.subarray(0, 11) + + expect(stage(state, manifest, descriptor, 0, first).acknowledgedOffset).toBe(first.length) + expect(inspectOrcadMigrationSnapshotUploads(manifest, storage)?.[0]?.receivedBytes).toBe( + first.length + ) + expect(stage(state, manifest, descriptor, 0, first).acknowledgedOffset).toBe(first.length) + expect(() => stage(state, manifest, descriptor, 0, Buffer.from('different!!'))).toThrow( + 'orcad_migration_snapshot_retry_mismatch' + ) + expect(() => stage(state, manifest, descriptor, first.length + 1, Buffer.from('x'))).toThrow( + 'orcad_migration_snapshot_offset_invalid' + ) + + stage(state, manifest, descriptor, first.length, bytes.subarray(first.length)) + assertOrcadMigrationSnapshotsReady(manifest, storage) + commitOrcadMigrationSnapshots(manifest, storage) + expect(readFinal(descriptor)).toEqual(bytes) + }) + + it('refuses incomplete and digest-mismatched staged bytes', () => { + const bytes = Buffer.from('expected bytes', 'utf8') + const descriptor = snapshot('2', 'tab-2', 'leaf-2', bytes) + const manifest = migrationManifest('refuse', [descriptor]) + const state = stagedState(manifest) + + stage(state, manifest, descriptor, 0, bytes.subarray(0, 4)) + expect(() => assertOrcadMigrationSnapshotsReady(manifest, storage)).toThrow( + 'orcad_migration_snapshot_incomplete' + ) + stage(state, manifest, descriptor, 4, Buffer.alloc(bytes.length - 4, 0x78)) + expect(() => assertOrcadMigrationSnapshotsReady(manifest, storage)).toThrow( + 'orcad_migration_snapshot_digest_mismatch' + ) + expect(readFinal(descriptor)).toBeNull() + }) + + it('recognizes an already-materialized matching final file after restart', () => { + const bytes = Buffer.from('already committed bytes', 'utf8') + const descriptor = snapshot('3', 'tab-3', 'leaf-3', bytes) + const manifest = migrationManifest('materialized', [descriptor]) + stagedState(manifest) + writeFinal(descriptor, bytes) + + expect(inspectOrcadMigrationSnapshotUploads(manifest, storage)?.[0]?.receivedBytes).toBe( + bytes.length + ) + assertOrcadMigrationSnapshotsReady(manifest, storage) + commitOrcadMigrationSnapshots(manifest, storage) + expect(readFinal(descriptor)).toEqual(bytes) + }) + + it('rejects a same-ref content conflict before materializing any snapshot', () => { + const firstBytes = Buffer.from('first snapshot', 'utf8') + const secondBytes = Buffer.from('second snapshot', 'utf8') + const first = snapshot('4', 'tab-4', 'leaf-4', firstBytes) + const second = snapshot('5', 'tab-5', 'leaf-5', secondBytes) + const manifest = migrationManifest('conflict', [first, second]) + const state = stagedState(manifest) + stage(state, manifest, first, 0, firstBytes) + stage(state, manifest, second, 0, secondBytes) + writeFinal(second, Buffer.alloc(secondBytes.length, 0x78)) + + expect(() => commitOrcadMigrationSnapshots(manifest, storage)).toThrow( + `orcad_migration_snapshot_destination_conflict:${second.ref}` + ) + expect(readFinal(first)).toBeNull() + expect(readFinal(second)).toEqual(Buffer.alloc(secondBytes.length, 0x78)) + }) + + it('removes only the selected staging tree and prunes unjournaled trees', () => { + const bytes = Buffer.from('staged bytes', 'utf8') + const firstDescriptor = snapshot('6', 'tab-6', 'leaf-6', bytes) + const secondDescriptor = snapshot('7', 'tab-7', 'leaf-7', bytes) + const first = migrationManifest('abort-first', [firstDescriptor]) + const second = migrationManifest('retain-second', [secondDescriptor]) + const state = stagedState(first, second) + stage(state, first, firstDescriptor, 0, bytes) + stage(state, second, secondDescriptor, 0, bytes) + + abortOrcadMigrationSnapshots(first, storage) + expect(inspectOrcadMigrationSnapshotUploads(first, storage)?.[0]?.receivedBytes).toBe(0) + expect(inspectOrcadMigrationSnapshotUploads(second, storage)?.[0]?.receivedBytes).toBe( + bytes.length + ) + pruneOrcadMigrationSnapshotStaging( + state.filter((entry) => entry.migrationId !== second.migrationId), + storage + ) + expect(inspectOrcadMigrationSnapshotUploads(second, storage)?.[0]?.receivedBytes).toBe(0) + }) +}) + +function snapshot( + suffix: string, + tabId: string, + leafId: string, + bytes: Buffer +): OrcadMigrationTerminalScrollbackSnapshot { + return { + tabId, + leafId, + ref: `v1-${suffix.repeat(32)}`, + sha256: createHash('sha256').update(bytes).digest('hex'), + byteLength: bytes.length + } +} + +function migrationManifest( + migrationId: string, + snapshots: OrcadMigrationTerminalScrollbackSnapshot[] +): OrcadMigrationManifest { + const workspaceSession = getDefaultWorkspaceSession() + workspaceSession.terminalLayoutsByTabId = Object.fromEntries( + snapshots.map((entry) => [ + entry.tabId, + { + root: { type: 'leaf' as const, leafId: entry.leafId }, + activeLeafId: entry.leafId, + expandedLeafId: null, + scrollbackRefsByLeafId: { [entry.leafId]: entry.ref } + } + ]) + ) + return { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId, + createdAt: '2026-08-30T12:00:00.000Z', + source: { sshTargetId: 'source', sshTargetGeneration: 1, targetLabel: 'Source' }, + payload: { + repositories: [], + projectGroups: [], + folderWorkspaces: [], + dormantState: { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + workspaceSession, + terminalScrollbackSnapshots: snapshots + } + }, + manifestSha256: 'a'.repeat(64) + } +} + +/** The manifests the destination importer holds staged. */ +function stagedState(...manifests: OrcadMigrationManifest[]): OrcadMigrationManifest[] { + return manifests +} + +function stage( + state: OrcadMigrationManifest[], + manifest: OrcadMigrationManifest, + descriptor: OrcadMigrationTerminalScrollbackSnapshot, + offset: number, + bytes: Buffer +) { + const request: OrcadMigrationSnapshotChunkRequest = { + migrationId: manifest.migrationId, + manifestSha256: manifest.manifestSha256, + ref: descriptor.ref, + offset, + bytesBase64: bytes.toString('base64') + } + const stagedManifest = state.find((entry) => entry.migrationId === manifest.migrationId) ?? null + return stageOrcadMigrationSnapshotChunk({ stagedManifest, storage, request }) +} + +function writeFinal(descriptor: OrcadMigrationTerminalScrollbackSnapshot, bytes: Buffer): void { + const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) + if (!path) { + throw new Error('expected snapshot path') + } + if (!storage.snapshotRoot) { + throw new Error('expected snapshot root') + } + mkdirSync(storage.snapshotRoot, { recursive: true }) + writeFileSync(path, bytes) +} + +function readFinal(descriptor: OrcadMigrationTerminalScrollbackSnapshot): Buffer | null { + const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) + return path && existsSync(path) ? readFileSync(path) : null +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts b/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts new file mode 100644 index 00000000000..80a7e566b71 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts @@ -0,0 +1,302 @@ +import { createHash } from 'node:crypto' +import { + closeSync, + existsSync, + fsyncSync, + linkSync, + mkdirSync, + openSync, + readFileSync, + readSync, + readdirSync, + rmSync, + statSync, + writeSync +} from 'node:fs' +import { join } from 'node:path' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { + decodeOrcadMigrationSnapshotChunk, + type OrcadMigrationSnapshotChunkRequest, + type OrcadMigrationSnapshotChunkResult, + type OrcadMigrationSnapshotUploadState, + type OrcadMigrationTerminalScrollbackSnapshot +} from '../../../shared/orcad-migration-scrollback' +import { syncDirectoryDurablySync } from '../../durable-file-write' +import { + getTerminalScrollbackSnapshotPath, + getTerminalScrollbackSnapshotRoot, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' + +const STAGING_DIRECTORY = '.orcad-migration-staging' + +/** + * Destination side: append one verified chunk to the staging file for a snapshot the staged + * manifest names. `stagedManifest` is the manifest the importer accepted; a request for any + * other migration or digest is refused. Retries of an already-written range must match it. + */ +export function stageOrcadMigrationSnapshotChunk(args: { + stagedManifest: OrcadMigrationManifest | null + storage: TerminalScrollbackSnapshotStorage + request: OrcadMigrationSnapshotChunkRequest +}): OrcadMigrationSnapshotChunkResult { + const manifest = requireStagedManifest(args.stagedManifest, args.request) + const descriptor = requireDescriptor(manifest, args.request.ref) + const bytes = decodeOrcadMigrationSnapshotChunk(args.request.bytesBase64) + const path = stagedSnapshotPath(args.storage, manifest, descriptor) + mkdirSync(stagingDirectory(args.storage, manifest), { recursive: true, mode: 0o700 }) + const descriptorFd = openStagedFile(path) + try { + const size = statSync(path).size + if (args.request.offset > size || args.request.offset + bytes.length > descriptor.byteLength) { + throw new Error('orcad_migration_snapshot_offset_invalid') + } + if (args.request.offset < size) { + if (args.request.offset + bytes.length > size) { + throw new Error('orcad_migration_snapshot_offset_invalid') + } + const existing = Buffer.alloc(bytes.length) + const read = readSync(descriptorFd, existing, 0, existing.length, args.request.offset) + if (read !== bytes.length || !existing.equals(bytes)) { + throw new Error('orcad_migration_snapshot_retry_mismatch') + } + return chunkResult(args.request, size) + } + const written = writeSync(descriptorFd, bytes, 0, bytes.length, args.request.offset) + if (written !== bytes.length) { + throw new Error('orcad_migration_snapshot_write_incomplete') + } + fsyncSync(descriptorFd) + return chunkResult(args.request, size + bytes.length) + } finally { + closeSync(descriptorFd) + } +} + +export function inspectOrcadMigrationSnapshotUploads( + manifest: OrcadMigrationManifest, + storage: TerminalScrollbackSnapshotStorage +): OrcadMigrationSnapshotUploadState[] | undefined { + const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] + if (snapshots.length === 0) { + return undefined + } + return snapshots.map((descriptor) => ({ + ...descriptor, + receivedBytes: matchingFinalSnapshot(storage, descriptor) + ? descriptor.byteLength + : stagedSnapshotSize(storage, manifest, descriptor) + })) +} + +export function assertOrcadMigrationSnapshotsReady( + manifest: OrcadMigrationManifest, + storage: TerminalScrollbackSnapshotStorage +): void { + for (const upload of inspectOrcadMigrationSnapshotUploads(manifest, storage) ?? []) { + const finalStatus = inspectFinalSnapshot(storage, upload) + if (finalStatus === 'conflict') { + throw new Error(`orcad_migration_snapshot_destination_conflict:${upload.ref}`) + } + if (upload.receivedBytes !== upload.byteLength) { + throw new Error(`orcad_migration_snapshot_incomplete:${upload.ref}`) + } + const path = + finalStatus === 'matching' + ? getTerminalScrollbackSnapshotPath(upload.ref, storage) + : stagedSnapshotPath(storage, manifest, upload) + if (!path || !fileMatches(path, upload)) { + throw new Error(`orcad_migration_snapshot_digest_mismatch:${upload.ref}`) + } + } +} + +export function commitOrcadMigrationSnapshots( + manifest: OrcadMigrationManifest, + storage: TerminalScrollbackSnapshotStorage +): void { + const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] + assertOrcadMigrationSnapshotsReady(manifest, storage) + const root = getTerminalScrollbackSnapshotRoot(storage) + mkdirSync(root, { recursive: true, mode: 0o700 }) + for (const descriptor of snapshots) { + if (matchingFinalSnapshot(storage, descriptor)) { + rmSync(stagedSnapshotPath(storage, manifest, descriptor), { force: true }) + continue + } + const stagedPath = stagedSnapshotPath(storage, manifest, descriptor) + const finalPath = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) + if (!finalPath) { + throw new Error('orcad_migration_snapshot_ref_invalid') + } + try { + linkSync(stagedPath, finalPath) + } catch (error) { + const finalStatus = inspectFinalSnapshot(storage, descriptor) + if (finalStatus === 'conflict') { + throw new Error(`orcad_migration_snapshot_destination_conflict:${descriptor.ref}`) + } + if (finalStatus !== 'matching') { + throw error + } + } + rmSync(stagedPath, { force: true }) + } + syncDirectoryDurablySync(root) + removeStagingDirectory(storage, manifest) +} + +export function abortOrcadMigrationSnapshots( + manifest: OrcadMigrationManifest, + storage: TerminalScrollbackSnapshotStorage +): void { + removeStagingDirectory(storage, manifest) +} + +/** Removes staging for every migration the importer no longer holds staged. */ +export function pruneOrcadMigrationSnapshotStaging( + stagedManifests: readonly OrcadMigrationManifest[], + storage: TerminalScrollbackSnapshotStorage +): void { + const root = join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY) + const retained = new Set(stagedManifests.map(stagingKey)) + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return + } + for (const entry of entries) { + if (/^[a-f0-9]{32}$/.test(entry) && !retained.has(entry)) { + rmSync(join(root, entry), { recursive: true, force: true }) + } + } +} + +function requireStagedManifest( + staged: OrcadMigrationManifest | null, + request: Pick +): OrcadMigrationManifest { + if ( + !staged || + staged.migrationId !== request.migrationId || + staged.manifestSha256 !== request.manifestSha256 + ) { + throw new Error('orcad_migration_snapshot_catalog_not_staged') + } + return staged +} + +function requireDescriptor( + manifest: OrcadMigrationManifest, + ref: string +): OrcadMigrationTerminalScrollbackSnapshot { + const descriptor = manifest.payload.dormantState?.terminalScrollbackSnapshots?.find( + (entry) => entry.ref === ref + ) + if (!descriptor) { + throw new Error('orcad_migration_snapshot_unknown') + } + return descriptor +} + +function stagedSnapshotSize( + storage: TerminalScrollbackSnapshotStorage, + manifest: OrcadMigrationManifest, + descriptor: OrcadMigrationTerminalScrollbackSnapshot +): number { + try { + return Math.min( + statSync(stagedSnapshotPath(storage, manifest, descriptor)).size, + descriptor.byteLength + ) + } catch { + return 0 + } +} + +function matchingFinalSnapshot( + storage: TerminalScrollbackSnapshotStorage, + descriptor: OrcadMigrationTerminalScrollbackSnapshot +): boolean { + return inspectFinalSnapshot(storage, descriptor) === 'matching' +} + +function inspectFinalSnapshot( + storage: TerminalScrollbackSnapshotStorage, + descriptor: OrcadMigrationTerminalScrollbackSnapshot +): 'absent' | 'matching' | 'conflict' { + const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) + if (!path || !existsSync(path)) { + return 'absent' + } + return fileMatches(path, descriptor) ? 'matching' : 'conflict' +} + +function fileMatches(path: string, descriptor: OrcadMigrationTerminalScrollbackSnapshot): boolean { + try { + const bytes = readFileSync(path) + return ( + bytes.length === descriptor.byteLength && + createHash('sha256').update(bytes).digest('hex') === descriptor.sha256 + ) + } catch { + return false + } +} + +function openStagedFile(path: string): number { + try { + return openSync(path, 'r+') + } catch { + try { + return openSync(path, 'wx+', 0o600) + } catch { + return openSync(path, 'r+') + } + } +} + +function stagingDirectory( + storage: TerminalScrollbackSnapshotStorage, + manifest: OrcadMigrationManifest +): string { + return join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY, stagingKey(manifest)) +} + +function stagedSnapshotPath( + storage: TerminalScrollbackSnapshotStorage, + manifest: OrcadMigrationManifest, + descriptor: OrcadMigrationTerminalScrollbackSnapshot +): string { + return join(stagingDirectory(storage, manifest), `${descriptor.ref}.${descriptor.sha256}.part`) +} + +function removeStagingDirectory( + storage: TerminalScrollbackSnapshotStorage, + manifest: OrcadMigrationManifest +): void { + const stagingRoot = join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY) + rmSync(stagingDirectory(storage, manifest), { recursive: true, force: true }) + syncDirectoryDurablySync(stagingRoot) +} + +function stagingKey(manifest: OrcadMigrationManifest): string { + return createHash('sha256') + .update(`${manifest.migrationId}\0${manifest.manifestSha256}`) + .digest('hex') + .slice(0, 32) +} + +function chunkResult( + request: OrcadMigrationSnapshotChunkRequest, + acknowledgedOffset: number +): OrcadMigrationSnapshotChunkResult { + return { + migrationId: request.migrationId, + manifestSha256: request.manifestSha256, + ref: request.ref, + acknowledgedOffset + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts new file mode 100644 index 00000000000..e71292242d0 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts @@ -0,0 +1,187 @@ +import { + isFinalAutomationRunStatus, + type Automation, + type AutomationRun +} from '../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationManifestSource +} from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey, + type OrcadMigrationSourceScope +} from './orcad-source-scope' + +export type OrcadMigrationSourceAutomationInspection = { + automations: Automation[] + automationRuns: AutomationRun[] + blockedAutomationCount: number + blockedRunCount: number +} + +export function collectOrcadMigrationSourceAutomationState( + state: PersistedState, + source: OrcadMigrationManifestSource, + catalog: OrcadMigrationCatalogPayload +): OrcadMigrationSourceAutomationInspection { + const scope = createOrcadMigrationSourceScope({ source, catalog }) + const touchedAutomations = state.automations.filter((entry) => + automationTouchesScope(entry, scope) + ) + const touchedAutomationIds = new Set(touchedAutomations.map((entry) => entry.id)) + const touchedRuns = state.automationRuns.filter( + (entry) => touchedAutomationIds.has(entry.automationId) || runTouchesScope(entry, scope) + ) + const runsByAutomationId = Map.groupBy(touchedRuns, (entry) => entry.automationId) + const automationsById = Map.groupBy(touchedAutomations, (entry) => entry.id) + const automations: Automation[] = [] + const automationRuns: AutomationRun[] = [] + let blockedAutomationCount = 0 + let blockedRunCount = 0 + + for (const [automationId, matching] of automationsById) { + const runs = runsByAutomationId.get(automationId) ?? [] + const eligible = + matching.length === 1 && + automationCanTransfer(matching[0], scope) && + runs.every((run) => runCanTransfer(run, scope)) + if (!eligible) { + blockedAutomationCount += matching.length + blockedRunCount += runs.length + continue + } + automations.push(projectAutomationToDestination(matching[0])) + automationRuns.push(...runs.map(projectAutomationRunToDestination)) + } + + for (const [automationId, runs] of runsByAutomationId) { + if (!automationsById.has(automationId)) { + blockedRunCount += runs.length + } + } + automations.sort((left, right) => compareKeys(left.id, right.id)) + automationRuns.sort((left, right) => compareKeys(left.id, right.id)) + return { automations, automationRuns, blockedAutomationCount, blockedRunCount } +} + +function automationTouchesScope(automation: Automation, scope: OrcadMigrationSourceScope): boolean { + return ( + (automation.executionTargetType === 'ssh' && automation.executionTargetId === scope.targetId) || + (scope.targetGeneration !== null && + automation.executionTargetGeneration === scope.targetGeneration) || + scope.repoIds.has(getAutomationRunRepoId(automation)) || + orcadMigrationOwnerMatchesScope(automation.workspaceId, scope) || + contextTouchesScope(automation.runContext, scope) || + contextTouchesScope(automation.sourceContext, scope) + ) +} + +function runTouchesScope(run: AutomationRun, scope: OrcadMigrationSourceScope): boolean { + return ( + orcadMigrationOwnerMatchesScope(run.workspaceId, scope) || + contextTouchesScope(run.runContext, scope) || + contextTouchesScope(run.sourceContext, scope) + ) +} + +function automationCanTransfer(automation: Automation, scope: OrcadMigrationSourceScope): boolean { + return ( + automation.enabled === false && + automation.executionTargetType === 'ssh' && + automation.executionTargetId === scope.targetId && + automation.schedulerOwner === 'ssh_bridge' && + (automation.executionTargetGeneration === undefined || + automation.executionTargetGeneration === scope.targetGeneration) && + scope.repoIds.has(getAutomationRunRepoId(automation)) && + ownerCanTransfer(automation.workspaceId, scope) && + contextCanTransfer(automation.runContext, scope) && + contextCanTransfer(automation.sourceContext, scope) + ) +} + +function runCanTransfer(run: AutomationRun, scope: OrcadMigrationSourceScope): boolean { + return ( + isFinalAutomationRunStatus(run.status) && + ownerCanTransfer(run.workspaceId, scope) && + contextCanTransfer(run.runContext, scope) && + contextCanTransfer(run.sourceContext, scope) + ) +} + +function ownerCanTransfer(value: string | null, scope: OrcadMigrationSourceScope): boolean { + return value === null || orcadMigrationOwnerMatchesScope(value, scope) +} + +function contextTouchesScope( + context: WorkspaceRunContext | TaskSourceContext | null | undefined, + scope: OrcadMigrationSourceScope +): boolean { + return ( + context?.hostId === scope.hostId || + (typeof context?.repoId === 'string' && scope.repoIds.has(context.repoId)) + ) +} + +function contextCanTransfer( + context: WorkspaceRunContext | TaskSourceContext | null | undefined, + scope: OrcadMigrationSourceScope +): boolean { + if (!context) { + return true + } + return ( + context.hostId === scope.hostId && + typeof context.repoId === 'string' && + scope.repoIds.has(context.repoId) && + (!context.projectHostSetupId || context.projectHostSetupId === context.repoId) + ) +} + +function projectAutomationToDestination(source: Automation): Automation { + const destination: Automation = { + ...structuredClone(source), + runContext: projectContextToDestination(source.runContext), + sourceContext: projectContextToDestination(source.sourceContext), + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'remote_host_service', + workspaceId: projectOwnerToDestination(source.workspaceId) + } + delete destination.executionTargetGeneration + return destination +} + +function projectAutomationRunToDestination(source: AutomationRun): AutomationRun { + return { + ...structuredClone(source), + runContext: projectContextToDestination(source.runContext), + sourceContext: projectContextToDestination(source.sourceContext), + workspaceId: projectOwnerToDestination(source.workspaceId) + } +} + +function projectContextToDestination( + context: T | null | undefined +): T | null { + if (!context) { + return null + } + return Object.assign(structuredClone(context), { + hostId: LOCAL_EXECUTION_HOST_ID, + projectHostSetupId: context.repoId ?? null + }) +} + +function projectOwnerToDestination(value: string | null): string | null { + return value === null ? null : unqualifyOrcadMigrationOwnerKey(value) +} + +function compareKeys(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts new file mode 100644 index 00000000000..fc902a994c5 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts @@ -0,0 +1,57 @@ +import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migration-manifest' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { SshTarget } from '../../../shared/ssh-types' +import type { Store } from '../../persistence' + +type OrcadSourceCatalogStore = Pick + +export function collectOrcadMigrationSourceCatalog( + store: OrcadSourceCatalogStore, + target: Pick +): OrcadMigrationCatalogPayload { + const repositories = store + .getRepos() + .filter((repo) => repo.connectionId === target.id) + .map((repo) => structuredClone(repo)) + const allGroups = store.getProjectGroups() + const groupConnectionById = new Map(allGroups.map((group) => [group.id, group.connectionId])) + const folderWorkspaces = store + .getFolderWorkspaces() + .filter( + (workspace) => + (workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) === + target.id + ) + .map((workspace) => structuredClone(workspace)) + const projectGroups = collectProjectGroups( + allGroups, + new Set([ + ...repositories.flatMap((repo) => (repo.projectGroupId ? [repo.projectGroupId] : [])), + ...folderWorkspaces.map((workspace) => workspace.projectGroupId), + ...allGroups.filter((group) => group.connectionId === target.id).map((group) => group.id) + ]) + ) + return { repositories, projectGroups, folderWorkspaces } +} + +function collectProjectGroups( + groups: ProjectGroup[], + initialIds: ReadonlySet +): ProjectGroup[] { + const byId = new Map(groups.map((group) => [group.id, group])) + const includedIds = new Set(initialIds) + const pending = [...initialIds] + while (pending.length > 0) { + const nextId = pending.pop() + if (!nextId) { + continue + } + const group = byId.get(nextId) + if (!group?.parentGroupId || includedIds.has(group.parentGroupId)) { + continue + } + includedIds.add(group.parentGroupId) + pending.push(group.parentGroupId) + } + return groups.filter((group) => includedIds.has(group.id)).map((group) => structuredClone(group)) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts new file mode 100644 index 00000000000..b139d31d17c --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts @@ -0,0 +1,73 @@ +import type { OrcadMigrationClientHostedBrowserCloseIntent } from '../../../shared/orcad-migration-client-state' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey, + type OrcadMigrationSourceScope +} from './orcad-source-scope' + +export function collectCloseIntents( + state: PersistedState, + scope: OrcadMigrationSourceScope, + destinationEnvironmentId: string | undefined, + eligibleSession: WorkspaceSessionState | undefined, + onBlocked: () => void +): OrcadMigrationClientHostedBrowserCloseIntent[] | undefined { + const eligiblePages = new Set() + for (const [ownerKey, pages] of Object.entries( + eligibleSession?.clientHostedBrowserPagesByWorktree ?? {} + )) { + if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + continue + } + const worktreeId = unqualifyOrcadMigrationOwnerKey(ownerKey) + for (const page of pages) { + eligiblePages.add(`${worktreeId}\0${page.browserPageId}`) + } + } + const result: OrcadMigrationClientHostedBrowserCloseIntent[] = [] + const seen = new Set() + const sessions = [ + state.workspaceSession, + ...Object.values(state.workspaceSessionsByHostId ?? {}).flatMap((entry) => + entry ? [entry] : [] + ) + ].filter((entry): entry is WorkspaceSessionState => Boolean(entry)) + for (const session of sessions) { + for (const [sourceEnvironmentId, intents] of Object.entries( + session.clientHostedBrowserCloseIntentsByEnvironment ?? {} + )) { + // Intents already keyed to the destination were handled by a prior retry. + if (sourceEnvironmentId === destinationEnvironmentId) { + continue + } + for (const intent of intents) { + if (!orcadMigrationOwnerMatchesScope(intent.worktreeId, scope)) { + continue + } + const worktreeId = unqualifyOrcadMigrationOwnerKey(intent.worktreeId) + const key = `${sourceEnvironmentId}\0${intent.browserPageId}\0${worktreeId}` + if ( + !eligiblePages.has(`${worktreeId}\0${intent.browserPageId}`) || + !destinationEnvironmentId + ) { + onBlocked() + continue + } + if (seen.has(key)) { + onBlocked() + continue + } + seen.add(key) + result.push({ + sourceEnvironmentId, + browserPageId: intent.browserPageId, + worktreeId, + closedAt: intent.closedAt + }) + } + } + } + return result.length > 0 ? result : undefined +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts new file mode 100644 index 00000000000..8f3abb11ea6 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from 'vitest' +import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { BrowserWorkspace } from '../../../shared/browser-workspace-types' +import { + CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, + type PersistedClientHostedBrowserPage +} from '../../../shared/client-hosted-browser-page-record' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { collectOrcadMigrationSourceClientState } from './orcad-source-client-state' + +const source = { + sshTargetId: 'target-1', + sshTargetGeneration: 3, + targetLabel: 'Build host' +} +const catalog: OrcadMigrationCatalogPayload = { + repositories: [ + { + id: 'repo-1', + path: '/srv/repo-1', + displayName: 'Repo', + badgeColor: '#737373', + addedAt: 1, + connectionId: source.sshTargetId, + executionHostId: 'ssh:target-1' + } + ], + projectGroups: [], + folderWorkspaces: [] +} + +function state(): PersistedState { + const persisted = getDefaultPersistedState('/home/test') + persisted.sshTargets = [ + { + id: source.sshTargetId, + label: source.targetLabel, + host: 'source.example.com', + port: 22, + username: 'deploy', + portForwards: [] + } + ] + return persisted +} + +function terminalTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session( + fields: Pick & Partial +): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), tabGroups: {}, ...fields } +} + +function browserWorkspace(id: string, worktreeId: string): BrowserWorkspace { + return { + id, + worktreeId, + url: 'about:blank', + title: id, + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } +} + +function hostedPage(browserPageId: string, workspaceId: string): PersistedClientHostedBrowserPage { + return { + v: CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, + browserPageId, + workspaceId, + browserProfileId: 'default', + url: 'about:blank', + title: browserPageId, + pairedDeviceId: 'device-1', + savedAt: 1 + } +} + +describe('source client-state migration', () => { + it('rekeys representable mobile selections and desktop routing', () => { + const current = state() + current.mobileClientTabSelectionsByDeviceId = { + phone: { + 'ssh:target-1|repo-1::/srv/worktree': { + activeTabId: 'tab-1', + activeGroupId: null, + activeTabIdByGroupId: {} + } + } + } + current.ui.lastActiveRepoId = 'repo-1' + current.ui.lastActiveWorktreeId = 'ssh:target-1|repo-1::/srv/worktree' + current.ui.workspaceHostScope = 'ssh:target-1' + current.ui.showDotfilesByWorktree = { + 'ssh:target-1|repo-1::/srv/worktree': false + } + const scoped = session({ + tabsByWorktree: { + 'ssh:target-1|repo-1::/srv/worktree': [ + terminalTab('tab-1', 'ssh:target-1|repo-1::/srv/worktree') + ] + }, + tabGroups: {} + }) + + const result = collectOrcadMigrationSourceClientState( + current, + source, + catalog, + 'environment-1', + scoped + ) + expect(result.blockedCounts).toEqual({ + 'mobile-tab-selection': 0, + 'ui-routing': 0, + 'saved-port-forward': 0 + }) + expect(result.payload?.mobileClientTabSelectionsByDeviceId).toMatchObject({ + phone: { 'repo-1::/srv/worktree': { activeTabId: 'tab-1' } } + }) + expect(result.payload?.uiRouting).toMatchObject({ + lastActiveRepoId: 'repo-1', + lastActiveWorktreeId: 'repo-1::/srv/worktree', + workspaceHostScope: 'local', + showDotfilesByWorktree: { 'repo-1::/srv/worktree': false } + }) + }) + + it('blocks a mobile selection that points at a group outside the migrated owner', () => { + const current = state() + const sourceOwner = 'ssh:target-1|repo-1::/srv/worktree' + const unrelatedOwner = 'ssh:target-1|repo-2::/srv/other' + current.mobileClientTabSelectionsByDeviceId = { + phone: { + [sourceOwner]: { + activeTabId: null, + activeGroupId: 'group-unrelated', + activeTabIdByGroupId: {} + } + } + } + const scoped = session({ + tabsByWorktree: {}, + tabGroups: { + [unrelatedOwner]: [ + { + id: 'group-unrelated', + worktreeId: unrelatedOwner, + activeTabId: null, + tabOrder: [] + } + ] + } + }) + + const result = collectOrcadMigrationSourceClientState( + current, + source, + catalog, + 'environment-1', + scoped + ) + + expect(result.payload?.mobileClientTabSelectionsByDeviceId).toBeUndefined() + expect(result.blockedCounts['mobile-tab-selection']).toBe(1) + }) + + it('blocks a per-group tab selection whose group is outside the migrated owner', () => { + const current = state() + const sourceOwner = 'ssh:target-1|repo-1::/srv/worktree' + const unrelatedOwner = 'ssh:target-1|repo-2::/srv/other' + current.mobileClientTabSelectionsByDeviceId = { + phone: { + [sourceOwner]: { + activeTabId: null, + activeGroupId: null, + activeTabIdByGroupId: { 'group-unrelated': 'tab-source' } + } + } + } + const scoped = session({ + tabsByWorktree: { + [sourceOwner]: [terminalTab('tab-source', sourceOwner)] + }, + tabGroups: { + [unrelatedOwner]: [ + { + id: 'group-unrelated', + worktreeId: unrelatedOwner, + activeTabId: 'tab-source', + tabOrder: ['tab-source'] + } + ] + } + }) + + const result = collectOrcadMigrationSourceClientState( + current, + source, + catalog, + 'environment-1', + scoped + ) + + expect(result.payload?.mobileClientTabSelectionsByDeviceId).toBeUndefined() + expect(result.blockedCounts['mobile-tab-selection']).toBe(1) + }) + + it('captures saved forwards and reports duplicate local ports', () => { + const current = state() + current.sshTargets[0].portForwards = [ + { localPort: 9000, remoteHost: '127.0.0.1', remotePort: 6768 }, + { localPort: 9000, remoteHost: '127.0.0.1', remotePort: 6769 } + ] + const result = collectOrcadMigrationSourceClientState( + current, + source, + catalog, + undefined, + undefined + ) + expect(result.payload?.savedPortForwards).toHaveLength(2) + expect(result.blockedCounts['saved-port-forward']).toBe(1) + }) + + it('captures only close intents for transferred client-hosted pages', () => { + const current = state() + const worktreeId = 'ssh:target-1|repo-1::/srv/worktree' + current.workspaceSession = session({ + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: { + [worktreeId]: [browserWorkspace('browser-1', worktreeId)] + }, + clientHostedBrowserPagesByWorktree: { + [worktreeId]: [hostedPage('page-1', 'browser-1')] + }, + clientHostedBrowserCloseIntentsByEnvironment: { + 'old-environment': [ + { browserPageId: 'page-1', worktreeId, closedAt: 42 }, + { browserPageId: 'unrelated-page', worktreeId, closedAt: 43 } + ], + 'environment-1': [{ browserPageId: 'destination-page', worktreeId, closedAt: 44 }] + } + }) + const result = collectOrcadMigrationSourceClientState( + current, + source, + catalog, + 'environment-1', + current.workspaceSession + ) + + expect(result.blockedCount).toBe(1) + expect(result.payload?.clientHostedBrowserCloseIntents).toEqual([ + { + sourceEnvironmentId: 'old-environment', + browserPageId: 'page-1', + worktreeId: 'repo-1::/srv/worktree', + closedAt: 42 + } + ]) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts new file mode 100644 index 00000000000..e01be755087 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts @@ -0,0 +1,258 @@ +import { hostStableKey } from '../../../shared/automation-owner-key' +import { parsePersistedAutomationHostFilter } from '../../../shared/automation-host-filter' +import type { + OrcadMigrationClientStatePayload, + OrcadMigrationUiRoutingState +} from '../../../shared/orcad-migration-client-state' +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationManifestSource +} from '../../../shared/orcad-migration-manifest' +import type { + PersistedMobileClientTabSelection, + PersistedState +} from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { orcadMigrationPaneBelongsToTabs } from './orcad-source-session-dependencies' +import { collectCloseIntents } from './orcad-source-client-browser-intents' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey +} from './orcad-source-scope' + +export type OrcadMigrationSourceClientStateInspection = { + payload: OrcadMigrationClientStatePayload | undefined + /** Invalid or unmatched closes are folded into the existing workspace-session blocker. */ + blockedCount: number + blockedCounts: { + 'mobile-tab-selection': number + 'ui-routing': number + 'saved-port-forward': number + } +} + +export function collectOrcadMigrationSourceClientState( + state: PersistedState, + source: OrcadMigrationManifestSource, + catalog: OrcadMigrationCatalogPayload, + destinationEnvironmentId: string | undefined, + eligibleSession: WorkspaceSessionState | undefined +): OrcadMigrationSourceClientStateInspection { + const scope = createOrcadMigrationSourceScope({ source, catalog }) + const blockedCounts = { + 'mobile-tab-selection': 0, + 'ui-routing': 0, + 'saved-port-forward': 0 + } + let blockedCount = 0 + const mobile = collectMobileSelections(state, scope, eligibleSession, blockedCounts) + const uiRouting = collectUiRouting( + state, + scope, + destinationEnvironmentId, + eligibleSession, + blockedCounts + ) + const target = state.sshTargets.find((entry) => entry.id === scope.targetId) + const savedPortForwards = target?.portForwards ? structuredClone(target.portForwards) : undefined + if (savedPortForwards) { + const ports = new Set() + for (const forward of savedPortForwards) { + if (ports.has(forward.localPort)) { + blockedCounts['saved-port-forward'] += 1 + } + ports.add(forward.localPort) + } + } + const closeIntents = collectCloseIntents( + state, + scope, + destinationEnvironmentId, + eligibleSession, + () => { + blockedCount += 1 + } + ) + const clientState: OrcadMigrationClientStatePayload = { + ...(mobile && Object.keys(mobile).length > 0 + ? { mobileClientTabSelectionsByDeviceId: mobile } + : {}), + ...(uiRouting && Object.keys(uiRouting).length > 0 ? { uiRouting } : {}), + ...(savedPortForwards && savedPortForwards.length > 0 ? { savedPortForwards } : {}), + ...(closeIntents && closeIntents.length > 0 + ? { clientHostedBrowserCloseIntents: closeIntents } + : {}) + } + return { + payload: Object.keys(clientState).length > 0 ? clientState : undefined, + blockedCount, + blockedCounts + } +} + +function collectMobileSelections( + state: PersistedState, + scope: ReturnType, + session: WorkspaceSessionState | undefined, + blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts'] +): + | NonNullable + | undefined { + const eligible = session ? collectEligibleSessionIdentity(session, scope) : null + const result: NonNullable< + OrcadMigrationClientStatePayload['mobileClientTabSelectionsByDeviceId'] + > = {} + const destinationKeys = new Set() + for (const [deviceId, selections] of Object.entries( + state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + const projected: Record = {} + for (const [ownerKey, selection] of Object.entries(selections)) { + if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + continue + } + const destinationKey = unqualifyOrcadMigrationOwnerKey(ownerKey) + if (destinationKeys.has(`${deviceId}\0${destinationKey}`)) { + blockedCounts['mobile-tab-selection'] += 1 + continue + } + if (!mobileSelectionIsRepresentable(selection, eligible)) { + blockedCounts['mobile-tab-selection'] += 1 + continue + } + destinationKeys.add(`${deviceId}\0${destinationKey}`) + projected[destinationKey] = structuredClone(selection) + } + if (Object.keys(projected).length > 0) { + result[deviceId] = projected + } + } + return Object.keys(result).length > 0 ? result : undefined +} + +function collectUiRouting( + state: PersistedState, + scope: ReturnType, + destinationEnvironmentId: string | undefined, + session: WorkspaceSessionState | undefined, + blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts'] +): OrcadMigrationUiRoutingState | undefined { + const ui = state.ui + const result: OrcadMigrationUiRoutingState = {} + if (ui.lastActiveRepoId && scope.repoIds.has(ui.lastActiveRepoId)) { + result.lastActiveRepoId = ui.lastActiveRepoId + } + if (ui.lastActiveWorktreeId && orcadMigrationOwnerMatchesScope(ui.lastActiveWorktreeId, scope)) { + result.lastActiveWorktreeId = unqualifyOrcadMigrationOwnerKey(ui.lastActiveWorktreeId) + } + const filterRepoIds = ui.filterRepoIds.filter((repoId) => scope.repoIds.has(repoId)) + if (filterRepoIds.length > 0) { + result.filterRepoIds = filterRepoIds + } + const dotfiles = Object.entries(ui.showDotfilesByWorktree ?? {}) + .filter(([ownerKey]) => orcadMigrationOwnerMatchesScope(ownerKey, scope)) + .map(([ownerKey, enabled]) => [unqualifyOrcadMigrationOwnerKey(ownerKey), enabled] as const) + if (dotfiles.length > 0) { + result.showDotfilesByWorktree = Object.fromEntries(dotfiles) + } + const dismissed = (ui.setupScriptPromptDismissedRepoIds ?? []).filter((repoId) => + scope.repoIds.has(repoId) + ) + if (dismissed.length > 0) { + result.setupScriptPromptDismissedRepoIds = dismissed + } + const manualOrder = (ui.manualRepoOrder ?? []) + .filter((entry) => entry.hostId === scope.hostId && scope.repoIds.has(entry.repoId)) + .map((entry) => ({ hostId: 'local' as const, repoId: entry.repoId })) + if (manualOrder.length > 0) { + result.manualRepoOrder = manualOrder + } + if (ui.workspaceHostScope === scope.hostId) { + result.workspaceHostScope = 'local' + } + const visibleHosts = (ui.visibleWorkspaceHostIds ?? []).filter( + (hostId) => hostId === scope.hostId + ) + if (visibleHosts.length > 0) { + result.visibleWorkspaceHostIds = ['local'] + } + const hostOrder = (ui.workspaceHostOrder ?? []).filter((hostId) => hostId === scope.hostId) + if (hostOrder.length > 0) { + result.workspaceHostOrder = ['local'] + } + const filter = parsePersistedAutomationHostFilter(ui.automationHostFilter) + if ( + filter.kind === 'host' && + hostStableKey(filter.host) === `host:desktop:ssh:${encodeURIComponent(scope.targetId)}` + ) { + result.automationHostFilter = destinationEnvironmentId + ? { + kind: 'host', + hostKey: hostStableKey({ + authority: { kind: 'runtime', environmentId: destinationEnvironmentId }, + selector: { kind: 'self' } + }) + } + : { kind: 'host', hostKey: 'host:desktop:self' } + } + const eligible = session ? collectEligibleSessionIdentity(session, scope) : null + const acknowledgements = Object.entries(ui.acknowledgedAgentsByPaneKey ?? {}).filter( + ([paneKey]) => { + const allowed = eligible ? orcadMigrationPaneBelongsToTabs(paneKey, eligible.tabIds) : false + if (!allowed) { + blockedCounts['ui-routing'] += 1 + } + return allowed + } + ) + if (acknowledgements.length > 0) { + result.acknowledgedAgentsByPaneKey = Object.fromEntries(acknowledgements) + } + return Object.keys(result).length > 0 ? result : undefined +} + +type EligibleSessionIdentity = { tabIds: ReadonlySet; groupIds: ReadonlySet } + +function collectEligibleSessionIdentity( + session: WorkspaceSessionState, + scope: ReturnType +): EligibleSessionIdentity { + const tabIds = new Set() + const groupIds = new Set() + for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { + if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + continue + } + tabs.forEach((tab) => tabIds.add(tab.id)) + } + for (const [ownerKey, groups] of Object.entries(session.tabGroups ?? {})) { + if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + continue + } + groups.forEach((group) => groupIds.add(group.id)) + } + return { tabIds, groupIds } +} + +function mobileSelectionIsRepresentable( + selection: PersistedMobileClientTabSelection, + eligible: EligibleSessionIdentity | null +): boolean { + if (!eligible) { + return ( + selection.activeTabId === null && + selection.activeGroupId === null && + Object.keys(selection.activeTabIdByGroupId).length === 0 + ) + } + if (selection.activeTabId !== null && !eligible.tabIds.has(selection.activeTabId)) { + return false + } + if (selection.activeGroupId !== null && !eligible.groupIds.has(selection.activeGroupId)) { + return false + } + return Object.entries(selection.activeTabIdByGroupId).every( + ([groupId, tabId]) => eligible.groupIds.has(groupId) && eligible.tabIds.has(tabId) + ) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts new file mode 100644 index 00000000000..49cb049057f --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { Automation, AutomationRun } from '../../../shared/automations-types' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationManifest +} from '../../../shared/orcad-migration-manifest' +import type { Repo } from '../../../shared/repo-types' +import type { SshTarget } from '../../../shared/ssh-types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { getRemoteRetirementNamespaceKey } from '../../worktree-name-retirement' +import { collectOrcadMigrationSourceDependencyCensus } from './orcad-source-dependency-census' + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 8 +} + +const REPO: Repo = { + id: 'repo-1', + path: '/srv/repo', + displayName: 'Repository', + badgeColor: '#737373', + addedAt: 1, + connectionId: TARGET.id, + executionHostId: `ssh:${TARGET.id}`, + projectGroupId: 'group-1' +} + +const FOLDER: FolderWorkspace = { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Folder', + folderPath: '/srv/folder', + connectionId: TARGET.id, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 +} + +const MANIFEST: OrcadMigrationManifest = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-1', + createdAt: '2026-08-30T12:00:00.000Z', + source: { + sshTargetId: TARGET.id, + sshTargetGeneration: TARGET.generation ?? null, + targetLabel: TARGET.label + }, + payload: { + repositories: [REPO], + projectGroups: [], + folderWorkspaces: [FOLDER] + }, + manifestSha256: 'a'.repeat(64) +} + +describe('orcad migration source dependency census', () => { + it('allows a static catalog with no unrepresented dependent state', () => { + const state = getDefaultPersistedState('/home/test') + state.sshTargets = [TARGET] + state.repos = [REPO] + state.folderWorkspaces = [FOLDER] + + expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toMatchObject({ + totalCount: 0 + }) + }) + + it('counts every currently unrepresented ownership surface before remote mutation', () => { + const state = getDefaultPersistedState('/home/test') + const worktreeId = `${REPO.id}::/srv/worktree` + const folderKey = folderWorkspaceKey(FOLDER.id) + state.sshTargets = [ + { + ...TARGET, + portForwards: [{ localPort: 3000, remoteHost: '127.0.0.1', remotePort: 3000 }] + } + ] + state.repos = [REPO] + state.folderWorkspaces = [FOLDER] + state.sshRemotePtyLeases = [ + { + targetId: TARGET.id, + ptyId: 'pty-1', + worktreeId, + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] + state.sshPtyConsumerRecoveries = [ + { + targetId: TARGET.id, + clientInstanceId: 'client-1', + serverBuildId: 'build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'lease-1' + } + ] + state.workspaceSessionsByHostId = { + [`ssh:${TARGET.id}`]: { + ...state.workspaceSession, + tabsByWorktree: { + [worktreeId]: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + } + state.worktreeMeta[worktreeId] = { + displayName: 'Worktree', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + hostId: `ssh:${TARGET.id}` + } + state.worktreeLineageById[worktreeId] = { + worktreeId, + worktreeInstanceId: 'instance-1', + parentWorktreeId: REPO.id, + parentWorktreeInstanceId: 'instance-parent', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] = { + childWorkspaceKey: worktreeWorkspaceKey(worktreeId), + parentWorkspaceKey: folderKey, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } + state.sparsePresetsByRepo[REPO.id] = [ + { + id: 'preset-1', + repoId: REPO.id, + name: 'UI', + directories: ['src/renderer'], + createdAt: 1, + updatedAt: 1 + } + ] + state.retiredWorktreeNamesByRepo![REPO.id] = { exhaustedTiers: 0, names: ['nautilus'] } + const namespaceKey = getRemoteRetirementNamespaceKey(REPO, state.settings, (targetId) => + state.sshTargets.find((target) => target.id === targetId) + ) + expect(namespaceKey).not.toBeNull() + if (!namespaceKey || !state.retiredWorktreeNamesByNamespace) { + throw new Error('expected source retirement namespace') + } + state.retiredWorktreeNamesByNamespace[namespaceKey] = { + exhaustedTiers: 0, + names: ['seahorse'] + } + state.automations = [automation()] + state.automationRuns = [automationRun()] + state.mobileClientTabSelectionsByDeviceId = { + 'device-1': { + [folderKey]: { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } + } + } + state.ui.lastActiveRepoId = REPO.id + + expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toEqual({ + totalCount: 14, + counts: { + automation: 1, + 'automation-run': 1, + 'mobile-tab-selection': 1, + 'retired-worktree-name': 2, + 'saved-port-forward': 1, + 'sparse-preset': 1, + 'terminal-lease': 1, + 'terminal-recovery': 1, + 'ui-routing': 1, + 'workspace-lineage': 1, + 'workspace-session': 1, + 'worktree-lineage': 1, + 'worktree-metadata': 1 + } + }) + }) + + it('keeps another SSH target and its host partition outside the source fence', () => { + const state = getDefaultPersistedState('/home/test') + state.sshTargets = [TARGET, { ...TARGET, id: 'ssh-other', generation: 9 }] + state.workspaceSessionsByHostId = { + 'ssh:ssh-other': { + ...state.workspaceSession, + tabsByWorktree: { + 'other-repo::/srv/worktree': [ + { + id: 'tab-other', + ptyId: 'pty-other', + worktreeId: 'other-repo::/srv/worktree', + title: 'Other', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + } + + expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST).totalCount).toBe(0) + }) + + it('does not strand a static migration on an owner-recovery tombstone after all leases are final', () => { + const state = getDefaultPersistedState('/home/test') + state.sshTargets = [TARGET] + state.repos = [REPO] + state.folderWorkspaces = [FOLDER] + state.sshRemotePtyLeases = [ + { + targetId: TARGET.id, + ptyId: 'pty-finished', + state: 'terminated', + createdAt: 1, + updatedAt: 2 + } + ] + state.sshPtyConsumerRecoveries = [ + { + targetId: TARGET.id, + clientInstanceId: 'client-1', + serverBuildId: 'build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'lease-1' + } + ] + + expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toMatchObject({ + totalCount: 0, + counts: { 'terminal-recovery': 0 } + }) + }) +}) + +function automation(): Automation { + return { + id: 'automation-1', + name: 'Remote task', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: REPO.id, + executionTargetType: 'ssh', + executionTargetId: TARGET.id, + executionTargetGeneration: TARGET.generation, + schedulerOwner: 'ssh_bridge', + workspaceMode: 'existing', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 60, + createdAt: 1, + updatedAt: 1 + } +} + +function automationRun(): AutomationRun { + return { + id: 'run-1', + automationId: 'automation-1', + title: 'Remote task #1', + scheduledFor: 1, + status: 'completed', + trigger: 'scheduled', + workspaceId: null, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null, + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: 1, + dispatchedAt: 1, + createdAt: 1 + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts new file mode 100644 index 00000000000..b6f317f4440 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts @@ -0,0 +1,257 @@ +import { parsePersistedAutomationHostFilter } from '../../../shared/automation-host-filter' +import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' +import { inspectOrcadSourceWorktreeMetadata } from './orcad-source-worktree-metadata' +import { + ORCAD_MIGRATION_DEPENDENCY_KINDS, + type OrcadMigrationDependencyKind +} from '../../../shared/orcad-migration-preflight' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' +import { isEmptyRetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' +import { extractRetiredNameRegistriesByNamespace } from '../../orca-profiles/profile-project-retired-name-transfer' +import { + inspectOrcadMigrationSourceSessions, + orcadMigrationPaneBelongsToTabs, + type OrcadMigrationSourceSessionInspection +} from './orcad-source-session-dependencies' +import { + collectOrcadMigrationSourceDormantState, + emptyDormantPayload, + ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS +} from './orcad-source-dormant-state' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + type OrcadMigrationSourceScope +} from './orcad-source-scope' + +export type OrcadMigrationSourceDependencyCensus = { + totalCount: number + counts: Record +} + +export function collectOrcadMigrationSourceDependencyCensus( + state: PersistedState, + manifest: OrcadMigrationManifest, + storage?: TerminalScrollbackSnapshotStorage +): OrcadMigrationSourceDependencyCensus { + return collectDependencyCensus(state, manifest, false, storage) +} + +export function collectOrcadMigrationUntransferredDependencyCensus( + state: PersistedState, + manifest: OrcadMigrationManifest, + storage?: TerminalScrollbackSnapshotStorage +): OrcadMigrationSourceDependencyCensus { + return collectDependencyCensus(state, manifest, true, storage) +} + +function collectDependencyCensus( + state: PersistedState, + manifest: OrcadMigrationManifest, + ignoreTransferredDormantState: boolean, + storage?: TerminalScrollbackSnapshotStorage +): OrcadMigrationSourceDependencyCensus { + const scope = createOrcadMigrationSourceScope({ + source: manifest.source, + catalog: manifest.payload + }) + const counts: Record = { + automation: 0, + 'automation-run': 0, + 'mobile-tab-selection': 0, + 'retired-worktree-name': 0, + 'saved-port-forward': 0, + 'sparse-preset': 0, + 'terminal-lease': 0, + 'terminal-recovery': 0, + 'ui-routing': 0, + 'workspace-lineage': 0, + 'workspace-session': 0, + 'worktree-lineage': 0, + 'worktree-metadata': 0 + } + const target = state.sshTargets.find((entry) => entry.id === scope.targetId) + counts['saved-port-forward'] = target?.portForwards?.length ?? 0 + counts['terminal-lease'] = state.sshRemotePtyLeases.filter( + (lease) => lease.targetId === scope.targetId && lease.state !== 'terminated' + ).length + + const sessions = inspectOrcadMigrationSourceSessions(state, { + hostId: scope.hostId, + ownerMatches: (ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope), + targetId: scope.targetId + }) + counts['workspace-session'] = sessions.dependencyCount + counts['terminal-recovery'] = countTerminalRecoveryState(state, scope, sessions) + const metadata = inspectOrcadSourceWorktreeMetadata(state, scope) + counts['worktree-metadata'] = metadata.rows.length + metadata.blockedCount + counts['worktree-lineage'] = Object.entries(state.worktreeLineageById).filter( + ([ownerKey, lineage]) => + orcadMigrationOwnerMatchesScope(ownerKey, scope) || + orcadMigrationOwnerMatchesScope(lineage.worktreeId, scope) || + orcadMigrationOwnerMatchesScope(lineage.parentWorktreeId, scope) + ).length + counts['workspace-lineage'] = Object.entries(state.workspaceLineageByChildKey).filter( + ([ownerKey, lineage]) => + orcadMigrationOwnerMatchesScope(ownerKey, scope) || + orcadMigrationOwnerMatchesScope(lineage.childWorkspaceKey, scope) || + orcadMigrationOwnerMatchesScope(lineage.parentWorkspaceKey, scope) + ).length + counts['sparse-preset'] = [...scope.repoIds].reduce( + (total, repoId) => total + (state.sparsePresetsByRepo[repoId]?.length ?? 0), + 0 + ) + counts['retired-worktree-name'] = countRetiredWorktreeNameState(state, manifest) + + const blockedAutomationIds = new Set( + state.automations + .filter((automation) => automationMatchesScope(automation, scope)) + .map(({ id }) => id) + ) + counts.automation = blockedAutomationIds.size + counts['automation-run'] = state.automationRuns.filter( + (run) => + blockedAutomationIds.has(run.automationId) || + orcadMigrationOwnerMatchesScope(run.workspaceId, scope) || + executionContextMatchesScope(run.runContext, scope) || + executionContextMatchesScope(run.sourceContext, scope) + ).length + counts['mobile-tab-selection'] = Object.values( + state.mobileClientTabSelectionsByDeviceId ?? {} + ).reduce( + (total, selections) => + total + + Object.keys(selections).filter((ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope)) + .length, + 0 + ) + counts['ui-routing'] = countUiRoutingState(state, scope, sessions) + const dormant = collectOrcadMigrationSourceDormantState( + state, + manifest.source, + manifest.payload, + storage, + manifest.destinationEnvironmentId + ) + const dormantMatches = + ignoreTransferredDormantState || + serializeOrcadMigrationValue(dormant.payload) === + serializeOrcadMigrationValue(manifest.payload.dormantState ?? emptyDormantPayload()) + if (dormantMatches) { + for (const kind of ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS) { + counts[kind] = dormant.blockedCounts[kind] + } + } + return { + counts, + totalCount: ORCAD_MIGRATION_DEPENDENCY_KINDS.reduce((total, kind) => total + counts[kind], 0) + } +} + +function countTerminalRecoveryState( + state: PersistedState, + scope: OrcadMigrationSourceScope, + sessions: OrcadMigrationSourceSessionInspection +): number { + // Expired routes may still own remote work; only terminated leases resolve recovery authority. + const hasActiveLease = state.sshRemotePtyLeases.some( + (lease) => lease.targetId === scope.targetId && lease.state !== 'terminated' + ) + let count = (state.sshPtyConsumerRecoveries ?? []).filter( + (recovery) => recovery.targetId === scope.targetId && hasActiveLease + ).length + count += state.migrationUnsupportedPtyEntries.filter( + (entry) => + orcadMigrationOwnerMatchesScope(entry.worktreeId, scope) || + (entry.tabId ? sessions.tabIds.has(entry.tabId) : false) || + sessions.ptyIds.has(entry.ptyId) || + (entry.paneKey ? orcadMigrationPaneBelongsToTabs(entry.paneKey, sessions.tabIds) : false) + ).length + count += state.legacyPaneKeyAliasEntries.filter( + (entry) => + orcadMigrationPaneBelongsToTabs(entry.legacyPaneKey, sessions.tabIds) || + orcadMigrationPaneBelongsToTabs(entry.stablePaneKey, sessions.tabIds) + ).length + return count +} + +function countRetiredWorktreeNameState( + state: PersistedState, + manifest: OrcadMigrationManifest +): number { + let count = manifest.payload.repositories.filter((repo) => { + const registry = state.retiredWorktreeNamesByRepo?.[repo.id] + return registry !== undefined && !isEmptyRetiredNameRegistry(registry) + }).length + const namespaceKeys = new Set() + for (const repo of manifest.payload.repositories) { + Object.keys(extractRetiredNameRegistriesByNamespace(state, repo)).forEach((key) => + namespaceKeys.add(key) + ) + } + count += namespaceKeys.size + return count +} + +function automationMatchesScope( + automation: PersistedState['automations'][number], + scope: OrcadMigrationSourceScope +): boolean { + return ( + (automation.executionTargetType === 'ssh' && automation.executionTargetId === scope.targetId) || + (scope.targetGeneration !== null && + automation.executionTargetGeneration === scope.targetGeneration) || + scope.repoIds.has(getAutomationRunRepoId(automation)) || + orcadMigrationOwnerMatchesScope(automation.workspaceId, scope) || + executionContextMatchesScope(automation.runContext, scope) || + executionContextMatchesScope(automation.sourceContext, scope) + ) +} + +function executionContextMatchesScope( + context: { hostId: string; repoId?: string | null } | null | undefined, + scope: OrcadMigrationSourceScope +): boolean { + return ( + context?.hostId === scope.hostId || + (typeof context?.repoId === 'string' && scope.repoIds.has(context.repoId)) + ) +} + +function countUiRoutingState( + state: PersistedState, + scope: OrcadMigrationSourceScope, + sessions: OrcadMigrationSourceSessionInspection +): number { + const ui = state.ui + let count = ui.lastActiveRepoId && scope.repoIds.has(ui.lastActiveRepoId) ? 1 : 0 + count += orcadMigrationOwnerMatchesScope(ui.lastActiveWorktreeId, scope) ? 1 : 0 + count += ui.filterRepoIds.filter((repoId) => scope.repoIds.has(repoId)).length + count += Object.keys(ui.showDotfilesByWorktree ?? {}).filter((ownerKey) => + orcadMigrationOwnerMatchesScope(ownerKey, scope) + ).length + count += (ui.setupScriptPromptDismissedRepoIds ?? []).filter((repoId) => + scope.repoIds.has(repoId) + ).length + count += (ui.manualRepoOrder ?? []).filter( + (entry) => entry.hostId === scope.hostId || scope.repoIds.has(entry.repoId) + ).length + count += ui.workspaceHostScope === scope.hostId ? 1 : 0 + count += (ui.visibleWorkspaceHostIds ?? []).filter((hostId) => hostId === scope.hostId).length + count += (ui.workspaceHostOrder ?? []).filter((hostId) => hostId === scope.hostId).length + const automationFilter = parsePersistedAutomationHostFilter(ui.automationHostFilter) + count += + automationFilter.kind === 'host' && + automationFilter.host.authority.kind === 'desktop' && + automationFilter.host.selector.kind === 'ssh' && + automationFilter.host.selector.targetId === scope.targetId + ? 1 + : 0 + count += Object.keys(ui.acknowledgedAgentsByPaneKey ?? {}).filter((paneKey) => + orcadMigrationPaneBelongsToTabs(paneKey, sessions.tabIds) + ).length + return count +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts new file mode 100644 index 00000000000..49cd5ce631a --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts @@ -0,0 +1,273 @@ +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationDormantStatePayload, + OrcadMigrationManifest, + OrcadMigrationManifestSource +} from '../../../shared/orcad-migration-manifest' +import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' +import type { OrcadMigrationDependencyKind } from '../../../shared/orcad-migration-preflight' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import { isEmptyRetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { toOrcadDestinationRepository } from './orcad-destination-catalog-projection' +import { collectOrcadMigrationRetiredWorktreeNamespaces } from './orcad-source-retired-worktree-names' +import { collectOrcadMigrationSourceAutomationState } from './orcad-source-automation-state' +import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey +} from './orcad-source-scope' +import { collectOrcadMigrationSourceClientState } from './orcad-source-client-state' +import { inspectOrcadSourceWorktreeMetadata } from './orcad-source-worktree-metadata' + +export const ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS = [ + 'worktree-metadata', + 'worktree-lineage', + 'workspace-lineage', + 'workspace-session', + 'automation', + 'automation-run', + 'sparse-preset', + 'retired-worktree-name', + 'mobile-tab-selection', + 'ui-routing', + 'saved-port-forward' +] as const satisfies readonly OrcadMigrationDependencyKind[] + +type TransferredDormantKind = (typeof ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS)[number] + +export type OrcadMigrationSourceDormantInspection = { + payload: OrcadMigrationDormantStatePayload + blockedCounts: Record +} + +export function collectOrcadMigrationSourceDormantState( + state: PersistedState, + source: OrcadMigrationManifestSource, + catalog: OrcadMigrationCatalogPayload, + storage?: TerminalScrollbackSnapshotStorage, + destinationEnvironmentId?: string +): OrcadMigrationSourceDormantInspection { + const scope = createOrcadMigrationSourceScope({ source, catalog }) + const blockedCounts = emptyBlockedCounts() + const destinationRepos = catalog.repositories.map(toOrcadDestinationRepository) + const setupByRepoId = new Map( + projectHostSetupProjectionFromRepos(destinationRepos).setups.flatMap((setup) => + setup.repoId ? [[setup.repoId, setup] as const] : [] + ) + ) + const metadata = inspectOrcadSourceWorktreeMetadata(state, scope) + blockedCounts['worktree-metadata'] = metadata.blockedCount + const worktreeMeta = uniqueDestinationRows( + metadata.rows.flatMap(({ sourceKey, meta }) => { + const worktreeId = unqualifyOrcadMigrationOwnerKey(sourceKey) + const setup = setupByRepoId.get(getRepoIdFromWorktreeId(worktreeId)) + return [ + { + sourceKey, + worktreeId, + meta: { + ...structuredClone(meta), + ...(setup ? { projectId: setup.projectId, projectHostSetupId: setup.id } : {}), + hostId: 'local' as const + } + } + ] + }), + (entry) => entry.worktreeId, + () => (blockedCounts['worktree-metadata'] += 1) + ) + const worktreeLineage = uniqueDestinationRows( + Object.entries(state.worktreeLineageById).flatMap(([sourceKey, lineage]) => { + const touches = [sourceKey, lineage.worktreeId, lineage.parentWorktreeId].some((value) => + orcadMigrationOwnerMatchesScope(value, scope) + ) + if (!touches) { + return [] + } + const worktreeId = unqualifyOrcadMigrationOwnerKey(sourceKey) + if ( + worktreeId !== lineage.worktreeId || + !orcadMigrationOwnerMatchesScope(lineage.worktreeId, scope) || + !orcadMigrationOwnerMatchesScope(lineage.parentWorktreeId, scope) + ) { + blockedCounts['worktree-lineage'] += 1 + return [] + } + return [{ sourceKey, worktreeId, lineage: structuredClone(lineage) }] + }), + (entry) => entry.worktreeId, + () => (blockedCounts['worktree-lineage'] += 1) + ) + const workspaceLineage = uniqueDestinationRows( + Object.entries(state.workspaceLineageByChildKey).flatMap(([sourceKey, lineage]) => { + const touches = [sourceKey, lineage.childWorkspaceKey, lineage.parentWorkspaceKey].some( + (value) => orcadMigrationOwnerMatchesScope(value, scope) + ) + if (!touches) { + return [] + } + const childWorkspaceKey = unqualifyOrcadMigrationOwnerKey(sourceKey) + if ( + childWorkspaceKey !== lineage.childWorkspaceKey || + !orcadMigrationOwnerMatchesScope(lineage.childWorkspaceKey, scope) || + !orcadMigrationOwnerMatchesScope(lineage.parentWorkspaceKey, scope) + ) { + blockedCounts['workspace-lineage'] += 1 + return [] + } + return [ + { + sourceKey, + childWorkspaceKey, + lineage: { + ...structuredClone(lineage), + childInstanceId: lineage.childInstanceId ?? null, + parentInstanceId: lineage.parentInstanceId ?? null + } + } + ] + }), + (entry) => entry.childWorkspaceKey, + () => (blockedCounts['workspace-lineage'] += 1) + ) + const sparsePresets = [...scope.repoIds] + .flatMap((repoId) => state.sparsePresetsByRepo[repoId] ?? []) + .map((preset) => structuredClone(preset)) + .sort((left, right) => + compareKeys(`${left.repoId}\0${left.id}`, `${right.repoId}\0${right.id}`) + ) + const retiredWorktreeNames = [...scope.repoIds] + .flatMap((repoId) => { + const registry = state.retiredWorktreeNamesByRepo?.[repoId] + return registry && !isEmptyRetiredNameRegistry(registry) + ? [{ repoId, registry: structuredClone(registry) }] + : [] + }) + .sort((left, right) => compareKeys(left.repoId, right.repoId)) + const workspaceSession = collectOrcadMigrationSourceWorkspaceSession( + state, + source, + catalog, + storage + ) + blockedCounts['workspace-session'] = workspaceSession.blockedCount + const automationState = collectOrcadMigrationSourceAutomationState(state, source, catalog) + blockedCounts.automation = automationState.blockedAutomationCount + blockedCounts['automation-run'] = automationState.blockedRunCount + const clientState = collectOrcadMigrationSourceClientState( + state, + source, + catalog, + destinationEnvironmentId, + workspaceSession.payload + ) + blockedCounts['mobile-tab-selection'] = clientState.blockedCounts['mobile-tab-selection'] + blockedCounts['ui-routing'] = clientState.blockedCounts['ui-routing'] + blockedCounts['saved-port-forward'] = clientState.blockedCounts['saved-port-forward'] + blockedCounts['workspace-session'] += clientState.blockedCount + return { + payload: { + version: 1, + worktreeMeta: worktreeMeta.sort((left, right) => + compareKeys(left.worktreeId, right.worktreeId) + ), + worktreeLineage: worktreeLineage.sort((left, right) => + compareKeys(left.worktreeId, right.worktreeId) + ), + workspaceLineage: workspaceLineage.sort((left, right) => + compareKeys(left.childWorkspaceKey, right.childWorkspaceKey) + ), + sparsePresets, + retiredWorktreeNames, + retiredWorktreeNamespaces: collectOrcadMigrationRetiredWorktreeNamespaces(state, catalog), + ...(workspaceSession.payload ? { workspaceSession: workspaceSession.payload } : {}), + ...(workspaceSession.snapshots.length > 0 + ? { terminalScrollbackSnapshots: workspaceSession.snapshots } + : {}), + ...(automationState.automations.length > 0 + ? { automations: automationState.automations } + : {}), + ...(automationState.automationRuns.length > 0 + ? { automationRuns: automationState.automationRuns } + : {}), + ...(clientState.payload ? { clientState: clientState.payload } : {}) + }, + blockedCounts + } +} + +export function orcadMigrationDormantStateMatchesSource( + state: PersistedState, + manifest: OrcadMigrationManifest, + storage?: TerminalScrollbackSnapshotStorage +): boolean { + const current = collectOrcadMigrationSourceDormantState( + state, + manifest.source, + manifest.payload, + storage, + manifest.destinationEnvironmentId + ).payload + return ( + serializeOrcadMigrationValue(current) === + serializeOrcadMigrationValue(manifest.payload.dormantState ?? emptyDormantPayload()) + ) +} + +export function emptyDormantPayload(): OrcadMigrationDormantStatePayload { + return { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [] + } +} + +function uniqueDestinationRows( + rows: T[], + key: (row: T) => string, + onDuplicate: () => void +): T[] { + const unique = new Map() + const duplicates = new Set() + for (const row of rows) { + const rowKey = key(row) + if (duplicates.has(rowKey)) { + onDuplicate() + } else if (unique.has(rowKey)) { + unique.delete(rowKey) + duplicates.add(rowKey) + onDuplicate() + } else { + unique.set(rowKey, row) + } + } + return [...unique.values()] +} + +function emptyBlockedCounts(): Record { + return { + 'worktree-metadata': 0, + 'worktree-lineage': 0, + 'workspace-lineage': 0, + 'workspace-session': 0, + automation: 0, + 'automation-run': 0, + 'sparse-preset': 0, + 'retired-worktree-name': 0, + 'mobile-tab-selection': 0, + 'ui-routing': 0, + 'saved-port-forward': 0 + } +} + +function compareKeys(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts new file mode 100644 index 00000000000..b386fec9cc3 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { SshTarget } from '../../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' +import { Store } from '../loading-store/store' +import { createOrcadMigrationManifest } from '../../ssh/orcad-migration-manifest-export' + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 3 +} +const REPO_ID = 'repo-1' +const WORKTREE_ID = `${REPO_ID}::/srv/app` + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function dormantSession(buffer: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'tab-1', + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + buffersByLeafId: { 'leaf-1': buffer } + } + } + } +} + +function sourceStore(): Store { + const directory = mkdtempSync(join(tmpdir(), 'orcad-source-export-')) + directories.push(directory) + const store = createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) + store.addSshTarget(TARGET) + store.addRepo({ + id: REPO_ID, + path: '/srv/app', + displayName: 'App', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId: TARGET.id + }) + store.setWorkspaceSession(dormantSession('dormant output\r\n'), toSshExecutionHostId(TARGET.id)) + return store +} + +describe('exporting a relay-hosted SSH target from the profile store', () => { + it('reads the target catalog and dormant scrollback without changing the source', () => { + const store = sourceStore() + const before = JSON.stringify({ + repos: store.getRepos(), + session: store.getWorkspaceSession(toSshExecutionHostId(TARGET.id)) + }) + + const manifest = createOrcadMigrationManifest(store, TARGET) + + expect(manifest.payload.repositories.map((repo) => repo.id)).toEqual([REPO_ID]) + const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] + expect(snapshots).toHaveLength(1) + const chunk = store.readOrcadMigrationSourceSnapshotChunk(manifest, snapshots[0]!.ref, 0) + expect(Buffer.from(chunk.bytesBase64, 'base64').toString('utf8')).toBe('dormant output\r\n') + expect(chunk.eof).toBe(true) + expect( + JSON.stringify({ + repos: store.getRepos(), + session: store.getWorkspaceSession(toSshExecutionHostId(TARGET.id)) + }) + ).toBe(before) + }) + + it('refuses a chunk once the dormant buffer changed after export', () => { + const store = sourceStore() + const manifest = createOrcadMigrationManifest(store, TARGET) + const ref = manifest.payload.dormantState?.terminalScrollbackSnapshots?.[0]?.ref ?? '' + store.setWorkspaceSession(dormantSession('rewritten output'), toSshExecutionHostId(TARGET.id)) + expect(() => store.readOrcadMigrationSourceSnapshotChunk(manifest, ref, 0)).toThrow( + 'orcad_migration_source_snapshot_changed' + ) + }) + + it('refuses a chunk for a manifest whose digest does not match its contents', () => { + const store = sourceStore() + const manifest = createOrcadMigrationManifest(store, TARGET) + const ref = manifest.payload.dormantState?.terminalScrollbackSnapshots?.[0]?.ref ?? '' + expect(() => + store.readOrcadMigrationSourceSnapshotChunk({ ...manifest, migrationId: 'forged' }, ref, 0) + ).toThrow('orcad_migration_manifest_digest_mismatch') + }) + + it('names what still blocks: nothing, once the dormant state is exportable', () => { + const store = sourceStore() + const manifest = createOrcadMigrationManifest(store, TARGET) + const census = store.inspectOrcadMigrationUntransferredDependencies(manifest) + expect(census.counts['workspace-session']).toBe(0) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts new file mode 100644 index 00000000000..8e3c118eee8 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts @@ -0,0 +1,128 @@ +/** + * The Store's read-only view of a relay-hosted SSH target, for migrating it to a managed orcad. + * + * Everything here reads the profile-state store and returns copies; nothing writes or retires + * source rows. Retiring the source after a verified import is the cutover's job (T8). + */ +import { + ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, + type OrcadMigrationTerminalScrollbackSnapshot +} from '../../../shared/orcad-migration-scrollback' +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationDormantStatePayload, + OrcadMigrationManifest, + OrcadMigrationManifestSource +} from '../../../shared/orcad-migration-manifest' +import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest' +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import { + collectOrcadMigrationSourceDependencyCensus, + collectOrcadMigrationUntransferredDependencyCensus, + type OrcadMigrationSourceDependencyCensus +} from './orcad-source-dependency-census' +import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' +import { readOrcadMigrationSourceScrollbackChunk } from './orcad-source-scrollback-state' + +type OrcadSourceExportRuntime = Pick< + StoreRuntimeState, + 'state' | 'terminalScrollbackSnapshotStorage' | 'retainedScrollbackRefsByMigrationId' +> + +const orcadSourceExportContext = Symbol('OrcadSourceExportPersistence') + +export class OrcadSourceExportPersistence { + readonly [orcadSourceExportContext]: OrcadSourceExportRuntime + + constructor(runtime: OrcadSourceExportRuntime) { + this[orcadSourceExportContext] = runtime + } + + collectOrcadMigrationSourceDormantState( + source: OrcadMigrationManifestSource, + catalog: OrcadMigrationCatalogPayload, + destinationEnvironmentId?: string + ): OrcadMigrationDormantStatePayload { + const runtime = this[orcadSourceExportContext] + return collectOrcadMigrationSourceDormantState( + runtime.state, + source, + catalog, + runtime.terminalScrollbackSnapshotStorage, + destinationEnvironmentId + ).payload + } + + /** Every dependency on the target, transferable or not; preflight decides what blocks. */ + inspectOrcadMigrationSourceDependencies( + manifest: OrcadMigrationManifest + ): OrcadMigrationSourceDependencyCensus { + const runtime = this[orcadSourceExportContext] + return collectOrcadMigrationSourceDependencyCensus( + runtime.state, + manifest, + runtime.terminalScrollbackSnapshotStorage + ) + } + + /** What still references the target that this manifest cannot carry. */ + inspectOrcadMigrationUntransferredDependencies( + manifest: OrcadMigrationManifest + ): OrcadMigrationSourceDependencyCensus { + const runtime = this[orcadSourceExportContext] + return collectOrcadMigrationUntransferredDependencyCensus( + runtime.state, + manifest, + runtime.terminalScrollbackSnapshotStorage + ) + } + + /** Keeps the snapshot files this manifest names until released, even if their tabs close. */ + retainOrcadMigrationScrollback(manifest: OrcadMigrationManifest): void { + const refs = (manifest.payload.dormantState?.terminalScrollbackSnapshots ?? []).map( + (snapshot) => snapshot.ref + ) + this[orcadSourceExportContext].retainedScrollbackRefsByMigrationId.set( + manifest.migrationId, + new Set(refs) + ) + } + + releaseOrcadMigrationScrollback(migrationId: string): void { + this[orcadSourceExportContext].retainedScrollbackRefsByMigrationId.delete(migrationId) + } + + /** + * One bounded chunk of a scrollback snapshot the signed manifest names. The bytes are checked + * against the manifest's length and digest, so a buffer that changed since export is refused. + */ + readOrcadMigrationSourceSnapshotChunk( + manifest: OrcadMigrationManifest, + ref: string, + offset: number + ): { bytesBase64: string; totalBytes: number; eof: boolean } { + assertOrcadMigrationManifestDigest(manifest) + const descriptor: OrcadMigrationTerminalScrollbackSnapshot | undefined = + manifest.payload.dormantState?.terminalScrollbackSnapshots?.find((entry) => entry.ref === ref) + if (!descriptor) { + throw new Error('orcad_migration_source_snapshot_unknown') + } + const runtime = this[orcadSourceExportContext] + return readOrcadMigrationSourceScrollbackChunk({ + state: runtime.state, + descriptor, + offset, + length: ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, + storage: runtime.terminalScrollbackSnapshotStorage + }) + } +} + +export function installOrcadSourceExportPersistenceContext( + target: OrcadSourceExportPersistence, + source: OrcadSourceExportPersistence +): void { + Object.defineProperty(target, orcadSourceExportContext, { + value: source[orcadSourceExportContext] + }) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts new file mode 100644 index 00000000000..897782737ad --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts @@ -0,0 +1,81 @@ +/** Retired worktree names (the name registry, not migration retirement) the target carries. */ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationDormantStatePayload +} from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { + isEmptyRetiredNameRegistry, + mergeRetiredNameRegistries, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' +import { getRemoteRetirementNamespaceKey } from '../../worktree-name-retirement' +import { + swapRetirementNamespaceHost, + retirementHostIdentity, + retirementNamespaceKeysToRead +} from '../../worktree-retirement-namespace' + +const EMPTY_REGISTRY: RetiredNameRegistry = { exhaustedTiers: 0, names: [] } + +export function collectOrcadMigrationRetiredWorktreeNamespaces( + state: PersistedState, + catalog: OrcadMigrationCatalogPayload +): OrcadMigrationDormantStatePayload['retiredWorktreeNamespaces'] { + const lookup = (targetId: string) => state.sshTargets.find((target) => target.id === targetId) + const byDestination = new Map< + string, + { sourceNamespaceKeys: Set; registry: RetiredNameRegistry } + >() + for (const repo of catalog.repositories) { + const canonicalSource = getRemoteRetirementNamespaceKey(repo, state.settings, lookup) + if (!canonicalSource) { + continue + } + const sourceNamespaceKeys = retirementNamespaceKeysToRead(repo, canonicalSource, lookup).filter( + (key) => state.retiredWorktreeNamesByNamespace?.[key] !== undefined + ) + if (sourceNamespaceKeys.length === 0) { + continue + } + const registry = sourceNamespaceKeys.reduce( + (merged, key) => + mergeRetiredNameRegistries( + merged, + state.retiredWorktreeNamesByNamespace?.[key] ?? { exhaustedTiers: 0, names: [] } + ), + EMPTY_REGISTRY + ) + if (isEmptyRetiredNameRegistry(registry)) { + continue + } + const namespaceKey = swapRetirementNamespaceHost( + canonicalSource, + retirementHostIdentity(repo, lookup), + LOCAL_EXECUTION_HOST_ID + ) + if (!namespaceKey) { + continue + } + const existing = byDestination.get(namespaceKey) + byDestination.set(namespaceKey, { + sourceNamespaceKeys: new Set([ + ...(existing?.sourceNamespaceKeys ?? []), + ...sourceNamespaceKeys + ]), + registry: existing ? mergeRetiredNameRegistries(existing.registry, registry) : registry + }) + } + return [...byDestination.entries()] + .map(([namespaceKey, entry]) => ({ + namespaceKey, + sourceNamespaceKeys: [...entry.sourceNamespaceKeys].sort(compareKeys), + registry: entry.registry + })) + .sort((left, right) => compareKeys(left.namespaceKey, right.namespaceKey)) +} + +function compareKeys(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts new file mode 100644 index 00000000000..26eade4c8b7 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope +} from './orcad-source-scope' + +const scope = createOrcadMigrationSourceScope({ + source: { sshTargetId: 'ssh-prod', sshTargetGeneration: 1, targetLabel: 'Production' }, + catalog: { + repositories: [ + { + id: 'repo-1', + path: '/srv/app', + displayName: 'App', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId: 'ssh-prod' + } + ], + projectGroups: [], + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Notes', + folderPath: '/srv/notes', + connectionId: 'ssh-prod', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ] + } +}) + +describe('migration source scope', () => { + it.each([ + ['a worktree of an exported repository', 'repo-1::/srv/app'], + ['an exported folder workspace', 'folder:folder-1'] + ])('owns %s', (_name, ownerKey) => { + expect(orcadMigrationOwnerMatchesScope(ownerKey, scope)).toBe(true) + }) + + it.each([ + ['another repository', 'repo-2::/srv/other'], + ['another folder workspace', 'folder:folder-2'], + ['no owner', null] + ])('does not own %s', (_name, ownerKey) => { + expect(orcadMigrationOwnerMatchesScope(ownerKey, scope)).toBe(false) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts new file mode 100644 index 00000000000..32157a6685d --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts @@ -0,0 +1,60 @@ +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationManifestSource +} from '../../../shared/orcad-migration-manifest' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import { ownerKeyBelongsToRepo } from '../../orca-profiles/profile-project-worktree-identity' + +export type OrcadMigrationSourceScope = { + targetId: string + targetGeneration: number | null + hostId: ReturnType + repoIds: ReadonlySet + folderWorkspaceKeys: ReadonlySet +} + +export function createOrcadMigrationSourceScope(args: { + source: OrcadMigrationManifestSource + catalog: OrcadMigrationCatalogPayload +}): OrcadMigrationSourceScope { + return { + targetId: args.source.sshTargetId, + targetGeneration: args.source.sshTargetGeneration, + hostId: toSshExecutionHostId(args.source.sshTargetId), + repoIds: new Set(args.catalog.repositories.map((repo) => repo.id)), + folderWorkspaceKeys: new Set( + args.catalog.folderWorkspaces.map((workspace) => `folder:${workspace.id}`) + ) + } +} + +export function orcadMigrationOwnerMatchesScope( + value: string | null | undefined, + scope: OrcadMigrationSourceScope +): boolean { + if (!value) { + return false + } + const rawValue = isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value + if (scope.folderWorkspaceKeys.has(rawValue)) { + return true + } + for (const repoId of scope.repoIds) { + if (ownerKeyBelongsToRepo(rawValue, repoId)) { + return true + } + } + const parsed = parseWorkspaceKey(rawValue) + return ( + parsed?.type === 'folder' && scope.folderWorkspaceKeys.has(`folder:${parsed.folderWorkspaceId}`) + ) +} + +export function unqualifyOrcadMigrationOwnerKey(value: string): string { + return isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts new file mode 100644 index 00000000000..516365f8fa3 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' +import { hasDuplicateOrcadMigrationScrollbackDescriptors } from './orcad-source-scrollback-state' + +const FIRST: OrcadMigrationTerminalScrollbackSnapshot = { + tabId: 'tab-1', + leafId: 'leaf-1', + ref: `v1-${'1'.repeat(32)}`, + sha256: 'a'.repeat(64), + byteLength: 1 +} + +describe('orcad source scrollback projection', () => { + it('refuses duplicate refs or tab/leaf identities across merged fragments', () => { + expect( + hasDuplicateOrcadMigrationScrollbackDescriptors([ + FIRST, + { ...FIRST, tabId: 'tab-2', leafId: 'leaf-2' } + ]) + ).toBe(true) + expect( + hasDuplicateOrcadMigrationScrollbackDescriptors([ + FIRST, + { ...FIRST, ref: `v1-${'2'.repeat(32)}` } + ]) + ).toBe(true) + expect( + hasDuplicateOrcadMigrationScrollbackDescriptors([ + FIRST, + { + ...FIRST, + tabId: 'tab-2', + leafId: 'leaf-2', + ref: `v1-${'2'.repeat(32)}` + } + ]) + ).toBe(false) + }) +}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts new file mode 100644 index 00000000000..668ef4ac59d --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts @@ -0,0 +1,158 @@ +import { createHash } from 'node:crypto' +import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' +import { + MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS, + MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES +} from '../../../shared/orcad-migration-scrollback' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + makeTerminalScrollbackSnapshotRef, + readTerminalScrollbackStoredBytesSync, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' + +export type ProjectedOrcadMigrationScrollback = { + session: WorkspaceSessionState + snapshots: OrcadMigrationTerminalScrollbackSnapshot[] + blockedCount: number +} + +export function hasDuplicateOrcadMigrationScrollbackDescriptors( + snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] +): boolean { + const refs = new Set() + const leaves = new Set() + for (const snapshot of snapshots) { + const leaf = `${snapshot.tabId}\0${snapshot.leafId}` + if (refs.has(snapshot.ref) || leaves.has(leaf)) { + return true + } + refs.add(snapshot.ref) + leaves.add(leaf) + } + return false +} + +export function projectOrcadMigrationSessionScrollback( + session: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage +): ProjectedOrcadMigrationScrollback { + const projected = structuredClone(session) + const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] + let blockedCount = 0 + let totalBytes = 0 + for (const [tabId, layout] of Object.entries(projected.terminalLayoutsByTabId)) { + const sourceLayout = session.terminalLayoutsByTabId[tabId] + const refs: Record = {} + const leafIds = new Set([ + ...Object.keys(sourceLayout.buffersByLeafId ?? {}), + ...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {}) + ]) + for (const leafId of [...leafIds].sort(compareKeys)) { + const buffer = sourceLayout.buffersByLeafId?.[leafId] + const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId] + const ref = buffer ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef + const bytes = buffer + ? Buffer.from(buffer, 'utf8') + : sourceRef + ? readTerminalScrollbackStoredBytesSync(sourceRef, storage) + : null + if ( + !ref || + !bytes || + bytes.length === 0 || + bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + ) { + blockedCount += 1 + continue + } + totalBytes += bytes.length + refs[leafId] = ref + snapshots.push({ + tabId, + leafId, + ref, + sha256: createHash('sha256').update(bytes).digest('hex'), + byteLength: bytes.length + }) + } + delete layout.buffersByLeafId + if (Object.keys(refs).length > 0) { + layout.scrollbackRefsByLeafId = refs + } else { + delete layout.scrollbackRefsByLeafId + } + } + if ( + snapshots.length > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS || + totalBytes > MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES + ) { + blockedCount += 1 + } + snapshots.sort((left, right) => + compareKeys(`${left.tabId}\0${left.leafId}`, `${right.tabId}\0${right.leafId}`) + ) + return { session: projected, snapshots, blockedCount } +} + +export function readOrcadMigrationSourceScrollbackChunk(args: { + state: PersistedState + descriptor: OrcadMigrationTerminalScrollbackSnapshot + offset: number + length: number + storage?: TerminalScrollbackSnapshotStorage +}): { bytesBase64: string; totalBytes: number; eof: boolean } { + const bytes = findSnapshotBytes(args.state, args.descriptor, args.storage) + if (!bytes) { + throw new Error('orcad_migration_source_snapshot_changed') + } + if (!Number.isSafeInteger(args.offset) || args.offset < 0 || args.offset > bytes.length) { + throw new Error('orcad_migration_source_snapshot_offset_invalid') + } + const end = Math.min(bytes.length, args.offset + args.length) + return { + bytesBase64: bytes.subarray(args.offset, end).toString('base64'), + totalBytes: bytes.length, + eof: end === bytes.length + } +} + +function findSnapshotBytes( + state: PersistedState, + descriptor: OrcadMigrationTerminalScrollbackSnapshot, + storage?: TerminalScrollbackSnapshotStorage +): Buffer | null { + for (const session of sessionPartitions(state)) { + const layout = session.terminalLayoutsByTabId[descriptor.tabId] + if (!layout) { + continue + } + const buffer = layout.buffersByLeafId?.[descriptor.leafId] + const ref = layout.scrollbackRefsByLeafId?.[descriptor.leafId] + const bytes = buffer + ? Buffer.from(buffer, 'utf8') + : ref === descriptor.ref + ? readTerminalScrollbackStoredBytesSync(ref, storage) + : null + if ( + bytes && + bytes.length === descriptor.byteLength && + createHash('sha256').update(bytes).digest('hex') === descriptor.sha256 + ) { + return bytes + } + } + return null +} + +function sessionPartitions(state: PersistedState): WorkspaceSessionState[] { + return [state.workspaceSession, ...Object.values(state.workspaceSessionsByHostId ?? {})].filter( + (session): session is WorkspaceSessionState => session !== undefined + ) +} + +function compareKeys(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts new file mode 100644 index 00000000000..c6f51cb82ed --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts @@ -0,0 +1,145 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' + +export type OrcadMigrationSourceSessionInspection = { + dependencyCount: number + ptyIds: Set + tabIds: Set +} + +type SessionScope = { + targetId: string + hostId: ExecutionHostId + ownerMatches: (ownerKey: string) => boolean +} + +export function inspectOrcadMigrationSourceSessions( + state: PersistedState, + scope: SessionScope +): OrcadMigrationSourceSessionInspection { + const aggregate: OrcadMigrationSourceSessionInspection = { + dependencyCount: 0, + ptyIds: new Set(), + tabIds: new Set() + } + const partitions: [string, WorkspaceSessionState][] = [ + ['local', state.workspaceSession], + ...Object.entries(state.workspaceSessionsByHostId ?? {}).flatMap( + ([hostId, session]): [string, WorkspaceSessionState][] => (session ? [[hostId, session]] : []) + ) + ] + for (const [hostId, session] of partitions) { + const inspected = inspectSession(session, scope, hostId === scope.hostId) + aggregate.dependencyCount += inspected.dependencyCount + inspected.ptyIds.forEach((value) => aggregate.ptyIds.add(value)) + inspected.tabIds.forEach((value) => aggregate.tabIds.add(value)) + } + return aggregate +} + +function inspectSession( + session: WorkspaceSessionState, + scope: SessionScope, + sourceHostPartition: boolean +): OrcadMigrationSourceSessionInspection { + const result: OrcadMigrationSourceSessionInspection = { + dependencyCount: 0, + ptyIds: new Set(), + tabIds: new Set() + } + const matchesOwner = (ownerKey: string): boolean => + Boolean(ownerKey) && (sourceHostPartition || scope.ownerMatches(ownerKey)) + for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { + if (!matchesOwner(ownerKey)) { + continue + } + result.dependencyCount += 1 + for (const tab of tabs) { + result.tabIds.add(tab.id) + if (tab.ptyId) { + result.ptyIds.add(tab.ptyId) + } + } + } + const browserWorkspaceIds = new Set() + for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { + if (!matchesOwner(ownerKey)) { + continue + } + result.dependencyCount += 1 + workspaces.forEach((workspace) => browserWorkspaceIds.add(workspace.id)) + } + for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { + if (!matchesOwner(ownerKey)) { + continue + } + for (const tab of tabs) { + if (tab.contentType === 'terminal') { + result.tabIds.add(tab.entityId) + } else if (tab.contentType === 'browser') { + browserWorkspaceIds.add(tab.entityId) + } + } + } + result.dependencyCount += countOwnedRecordKeys(session, matchesOwner) + result.dependencyCount += Object.keys(session.browserPagesByWorkspace ?? {}).filter( + (workspaceId) => browserWorkspaceIds.has(workspaceId) + ).length + result.dependencyCount += Object.keys(session.terminalLayoutsByTabId).filter((tabId) => + result.tabIds.has(tabId) + ).length + result.dependencyCount += Object.keys(session.remoteSessionIdsByTabId ?? {}).filter((tabId) => + result.tabIds.has(tabId) + ).length + for (const paneKey of Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {})) { + if (orcadMigrationPaneBelongsToTabs(paneKey, result.tabIds)) { + result.dependencyCount += 1 + } + } + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + if (matchesOwner(tombstone.worktreeId)) { + result.dependencyCount += 1 + result.ptyIds.add(tombstone.ptyId) + } + } + for (const sleeping of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + if (matchesOwner(sleeping.worktreeId) || sleeping.connectionId === scope.targetId) { + result.dependencyCount += 1 + } + } + result.dependencyCount += (session.activeWorktreeIdsOnShutdown ?? []).filter(matchesOwner).length + result.dependencyCount += session.activeRepoId && scope.ownerMatches(session.activeRepoId) ? 1 : 0 + result.dependencyCount += matchesOwner(session.activeWorktreeId ?? '') ? 1 : 0 + result.dependencyCount += matchesOwner(session.activeWorkspaceKey ?? '') ? 1 : 0 + result.dependencyCount += session.activeWorkspaceExecutionHostId === scope.hostId ? 1 : 0 + result.dependencyCount += (session.activeConnectionIdsAtShutdown ?? []).filter( + (targetId) => targetId === scope.targetId + ).length + result.dependencyCount += session.activeTabId && result.tabIds.has(session.activeTabId) ? 1 : 0 + return result +} + +function countOwnedRecordKeys( + session: WorkspaceSessionState, + matchesOwner: (ownerKey: string) => boolean +): number { + let count = 0 + for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) { + count += Object.keys(session[field] ?? {}).filter(matchesOwner).length + } + return count +} + +export function orcadMigrationPaneBelongsToTabs( + paneKey: string, + tabIds: ReadonlySet +): boolean { + for (const tabId of tabIds) { + if (paneKey.startsWith(`${tabId}:`)) { + return true + } + } + return false +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts new file mode 100644 index 00000000000..2a43579a9b8 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts @@ -0,0 +1,245 @@ +import { isWorkspaceKey } from '../../../shared/workspace-scope' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { buildMarkdownFrontmatterIdMap } from '../../orca-profiles/profile-session-owner-transfer' +import { + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey, + type OrcadMigrationSourceScope +} from './orcad-source-scope' +import { + paneBelongsToTabs, + paneBelongsToTerminalLayout +} from './orcad-source-workspace-session-layout' +import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragments' + +export function countUnrepresentableMarkdownState( + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope, + sourceHostPartition: boolean +): number { + const projection = { + mapOwnerKey: (ownerKey: string) => + sourceHostPartition || orcadMigrationOwnerMatchesScope(ownerKey, scope) + ? unqualifyOrcadMigrationOwnerKey(ownerKey) + : null, + mapWorktreeId: unqualifyOrcadMigrationOwnerKey + } + const mappings = buildMarkdownFrontmatterIdMap(session.openFilesByWorktree, projection) + return Object.keys(session.markdownFrontmatterVisible ?? {}).filter( + (fileId) => mappings.get(fileId) === null + ).length +} + +export function countUnsupportedSessionState( + state: PersistedState, + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope, + sourceHostPartition: boolean, + terminalTabIds: ReadonlySet +): number { + const owns = (ownerKey: string): boolean => orcadMigrationOwnerMatchesScope(ownerKey, scope) + const matches = (ownerKey: string): boolean => + Boolean(ownerKey) && (sourceHostPartition || owns(ownerKey)) + let count = sourceHostPartition + ? [...collectSessionOwnerKeys(session)].filter((ownerKey) => !owns(ownerKey)).length + : 0 + for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { + if (!owns(ownerKey)) { + continue + } + tabs.forEach((tab) => { + count += tab.ptyId ? 1 : 0 + }) + } + for (const tabId of terminalTabIds) { + const layout = session.terminalLayoutsByTabId[tabId] + count += Object.keys(layout?.ptyIdsByLeafId ?? {}).length + count += session.remoteSessionIdsByTabId?.[tabId] ? 1 : 0 + } + count += Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).filter((paneKey) => + paneBelongsToTabs(paneKey, terminalTabIds) + ).length + count += Object.values(session.sleepingAgentSessionsByPaneKey ?? {}).filter((record) => { + const touchesSource = matches(record.worktreeId) || record.connectionId === scope.targetId + return ( + touchesSource && !transferableSleepingAgentSession(record, session, scope, terminalTabIds) + ) + }).length + count += Object.entries(session.clientHostedBrowserPagesByWorktree ?? {}) + .filter(([ownerKey]) => matches(ownerKey)) + .filter( + ([ownerKey, pages]) => !clientHostedPagesAreTransferable(session, ownerKey, pages) + ).length + count += (session.activeWorktreeIdsOnShutdown ?? []).filter( + (worktreeId) => + matches(worktreeId) && + shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) + ).length + count += + session.activeRepoId && + owns(session.activeRepoId) && + !(sourceHostPartition && scope.repoIds.has(session.activeRepoId)) + ? 1 + : 0 + count += + session.activeWorktreeId && + matches(session.activeWorktreeId) && + !(sourceHostPartition && orcadMigrationOwnerMatchesScope(session.activeWorktreeId, scope)) + ? 1 + : 0 + count += + session.activeWorkspaceKey && + matches(session.activeWorkspaceKey) && + !(sourceHostPartition && orcadMigrationOwnerMatchesScope(session.activeWorkspaceKey, scope)) + ? 1 + : 0 + count += session.activeWorkspaceExecutionHostId === scope.hostId && !sourceHostPartition ? 1 : 0 + count += (session.activeConnectionIdsAtShutdown ?? []).filter( + (targetId) => targetId === scope.targetId + ).length + count += + session.activeTabId && terminalTabIds.has(session.activeTabId) && !sourceHostPartition ? 1 : 0 + for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { + if (owns(ownerKey)) { + count += files.filter( + (file) => file.externalSshTargetId !== undefined || Boolean(file.runtimeEnvironmentId) + ).length + } + } + for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { + if (owns(ownerKey)) { + count += workspaces.filter((workspace) => + Boolean(workspace.sessionProfileId || workspace.sessionPartition) + ).length + } + } + for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { + if (owns(ownerKey)) { + count += tabs.filter( + (tab) => tab.executionHostId !== undefined && tab.executionHostId !== scope.hostId + ).length + } + } + return count +} + +export function shutdownMarkerHasTerminalAuthority( + state: PersistedState, + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope, + sourceHostPartition: boolean, + worktreeId: string +): boolean { + const marker = unqualifyOrcadMigrationOwnerKey(worktreeId) + const ownerMatchesMarker = (ownerKey: string): boolean => + (sourceHostPartition || orcadMigrationOwnerMatchesScope(ownerKey, scope)) && + unqualifyOrcadMigrationOwnerKey(ownerKey) === marker + const tabIds = new Set() + for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { + if (!ownerMatchesMarker(ownerKey)) { + continue + } + for (const tab of tabs) { + tabIds.add(tab.id) + if (tab.ptyId) { + return true + } + const layout = session.terminalLayoutsByTabId[tab.id] + if (Object.keys(layout?.ptyIdsByLeafId ?? {}).length > 0) { + return true + } + if (session.remoteSessionIdsByTabId?.[tab.id]) { + return true + } + } + } + if ( + Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).some((paneKey) => + paneBelongsToTabs(paneKey, tabIds) + ) + ) { + return true + } + return state.sshRemotePtyLeases.some( + (lease) => + lease.targetId === scope.targetId && + lease.state !== 'terminated' && + (lease.worktreeId === undefined || + unqualifyOrcadMigrationOwnerKey(lease.worktreeId) === marker) + ) +} + +function clientHostedPagesAreTransferable( + session: WorkspaceSessionState, + ownerKey: string, + pages: NonNullable[string] +): boolean { + const browserWorkspaceIds = new Set( + (session.browserTabsByWorktree?.[ownerKey] ?? []).map((workspace) => workspace.id) + ) + return pages.every((page) => browserWorkspaceIds.has(page.workspaceId)) +} + +export function projectDormantSessionFocus( + source: WorkspaceSessionState, + transferred: WorkspaceSessionState, + scope: OrcadMigrationSourceScope, + terminalTabIds: ReadonlySet +): void { + // These scalars are UI focus, not execution ownership. They are safe to carry + // only from the source host partition and only when they point at an entity + // already proven dormant and included in the projected session. + if (source.activeRepoId && scope.repoIds.has(source.activeRepoId)) { + transferred.activeRepoId = source.activeRepoId + } + if (source.activeWorktreeId && orcadMigrationOwnerMatchesScope(source.activeWorktreeId, scope)) { + transferred.activeWorktreeId = unqualifyOrcadMigrationOwnerKey(source.activeWorktreeId) + } + const activeWorkspaceKey = + source.activeWorkspaceKey && orcadMigrationOwnerMatchesScope(source.activeWorkspaceKey, scope) + ? unqualifyOrcadMigrationOwnerKey(source.activeWorkspaceKey) + : null + if (activeWorkspaceKey && isWorkspaceKey(activeWorkspaceKey)) { + transferred.activeWorkspaceKey = activeWorkspaceKey + } + if (source.activeWorkspaceExecutionHostId === scope.hostId) { + transferred.activeWorkspaceExecutionHostId = LOCAL_EXECUTION_HOST_ID + } + if (source.activeTabId && terminalTabIds.has(source.activeTabId)) { + transferred.activeTabId = source.activeTabId + } +} + +export function projectSessionToDestination( + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope +): WorkspaceSessionState { + const projected = structuredClone(session) + for (const tabs of Object.values(projected.unifiedTabs ?? {})) { + for (const tab of tabs) { + if (tab.executionHostId === scope.hostId) { + tab.executionHostId = LOCAL_EXECUTION_HOST_ID + } + } + } + return projected +} + +export function transferableSleepingAgentSession( + record: SleepingAgentSessionRecord, + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope, + terminalTabIds: ReadonlySet +): boolean { + return ( + orcadMigrationOwnerMatchesScope(record.worktreeId, scope) && + (record.connectionId == null || record.connectionId === scope.targetId) && + // Older profiles can still contain a worker-resume fence; never discard its authority. + (!('automaticResumeBlockedBy' in record) || record.automaticResumeBlockedBy === undefined) && + ((record.origin ?? 'worktree-sleep') === 'worktree-sleep' || + paneBelongsToTerminalLayout(record, session, terminalTabIds)) + ) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts new file mode 100644 index 00000000000..3f3bf33da9e --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts @@ -0,0 +1,113 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { mergeWorkspaceSessions } from '../../orca-profiles/profile-project-session-state' +import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' + +export function sessionPartitions( + state: { + workspaceSession: WorkspaceSessionState + workspaceSessionsByHostId?: Record + }, + localHostId: string +): [string, WorkspaceSessionState][] { + return [ + [localHostId, state.workspaceSession], + ...Object.entries(state.workspaceSessionsByHostId ?? {}).flatMap( + ([hostId, session]): [string, WorkspaceSessionState][] => (session ? [[hostId, session]] : []) + ) + ] +} + +export function mergeSessionFragments( + fragments: WorkspaceSessionState[] +): WorkspaceSessionState | null { + const ownerKeys = new Set() + const entityKeys = new Set() + let merged: WorkspaceSessionState | undefined + for (const fragment of fragments) { + if ( + hasDuplicates(ownerKeys, collectSessionOwnerKeys(fragment)) || + hasDuplicates(entityKeys, collectSessionEntityKeys(fragment)) + ) { + return null + } + merged = mergeWorkspaceSessions(merged, fragment) + } + return merged ?? null +} + +export function collectSessionOwnerKeys(session: WorkspaceSessionState): Set { + const keys = new Set() + const fields = [ + 'tabsByWorktree', + 'openFilesByWorktree', + 'browserTabsByWorktree', + 'unifiedTabs', + 'tabGroups', + ...SESSION_FIELDS_PRUNED_BY_OWNER_KEY + ] as const + for (const field of fields) { + Object.keys(session[field] ?? {}).forEach((key) => keys.add(key)) + } + Object.values(session.tabsByWorktree) + .flat() + .forEach((entry) => keys.add(entry.worktreeId)) + Object.values(session.openFilesByWorktree ?? {}) + .flat() + .forEach((entry) => keys.add(entry.worktreeId)) + Object.values(session.browserTabsByWorktree ?? {}) + .flat() + .forEach((entry) => keys.add(entry.worktreeId)) + Object.values(session.browserPagesByWorkspace ?? {}) + .flat() + .forEach((entry) => keys.add(entry.worktreeId)) + Object.values(session.unifiedTabs ?? {}) + .flat() + .forEach((entry) => keys.add(entry.worktreeId)) + Object.values(session.tabGroups ?? {}) + .flat() + .forEach((entry) => keys.add(entry.worktreeId)) + Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {}).forEach((entry) => + keys.add(entry.worktreeId) + ) + Object.values(session.sleepingAgentSessionsByPaneKey ?? {}).forEach((entry) => + keys.add(entry.worktreeId) + ) + return keys +} + +export function collectSessionEntityKeys(session: WorkspaceSessionState): string[] { + const keys: string[] = [] + Object.values(session.tabsByWorktree) + .flat() + .forEach((tab) => keys.push(`terminal:${tab.id}`)) + Object.values(session.browserTabsByWorktree ?? {}) + .flat() + .forEach((tab) => keys.push(`browser:${tab.id}`)) + Object.values(session.clientHostedBrowserPagesByWorktree ?? {}) + .flat() + .forEach((page) => keys.push(`client-browser-page:${page.browserPageId}`)) + Object.values(session.unifiedTabs ?? {}) + .flat() + .forEach((tab) => keys.push(`tab:${tab.id}`)) + Object.values(session.tabGroups ?? {}) + .flat() + .forEach((group) => keys.push(`group:${group.id}`)) + Object.keys(session.terminalSurfaceTombstonesByPaneKey ?? {}).forEach((key) => + keys.push(`tombstone:${key}`) + ) + Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).forEach((key) => + keys.push(`sleeping-agent:${key}`) + ) + return keys +} + +function hasDuplicates(seen: Set, incoming: Iterable): boolean { + let duplicate = false + for (const key of incoming) { + if (seen.has(key)) { + duplicate = true + } + seen.add(key) + } + return duplicate +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts new file mode 100644 index 00000000000..c541bad0cba --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts @@ -0,0 +1,64 @@ +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + orcadMigrationOwnerMatchesScope, + type OrcadMigrationSourceScope +} from './orcad-source-scope' + +export function collectOwnedTerminalTabIds( + session: WorkspaceSessionState, + scope: OrcadMigrationSourceScope +): Set { + const tabIds = new Set( + Object.entries(session.tabsByWorktree).flatMap(([ownerKey, tabs]) => + orcadMigrationOwnerMatchesScope(ownerKey, scope) ? tabs.map((tab) => tab.id) : [] + ) + ) + for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { + if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { + continue + } + for (const tab of tabs) { + if (tab.contentType === 'terminal') { + tabIds.add(tab.id) + tabIds.add(tab.entityId) + } + } + } + return tabIds +} + +export function paneBelongsToTerminalLayout( + record: SleepingAgentSessionRecord, + session: WorkspaceSessionState, + terminalTabIds: ReadonlySet +): boolean { + const separator = record.paneKey.lastIndexOf(':') + if (separator < 1) { + return false + } + const tabId = record.paneKey.slice(0, separator) + const leafId = record.paneKey.slice(separator + 1) + if ((record.tabId !== undefined && record.tabId !== tabId) || !terminalTabIds.has(tabId)) { + return false + } + return terminalLayoutContainsLeaf(session.terminalLayoutsByTabId[tabId]?.root, leafId) +} + +function terminalLayoutContainsLeaf( + node: WorkspaceSessionState['terminalLayoutsByTabId'][string]['root'] | undefined, + leafId: string +): boolean { + return Boolean( + node && + (node.type === 'leaf' + ? node.leafId === leafId + : terminalLayoutContainsLeaf(node.first, leafId) || + terminalLayoutContainsLeaf(node.second, leafId)) + ) +} + +export function paneBelongsToTabs(paneKey: string, tabIds: ReadonlySet): boolean { + const separator = paneKey.lastIndexOf(':') + return separator > 0 && tabIds.has(paneKey.slice(0, separator)) +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts new file mode 100644 index 00000000000..e56a1943a57 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -0,0 +1,115 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { + OrcadMigrationCatalogPayload, + OrcadMigrationManifestSource +} from '../../../shared/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' +import { + extractSessionOwnersForTransfer, + hasTransferredSessionState +} from '../../orca-profiles/profile-session-owner-transfer' +import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey +} from './orcad-source-scope' +import { + countUnrepresentableMarkdownState, + countUnsupportedSessionState, + projectDormantSessionFocus, + projectSessionToDestination, + shutdownMarkerHasTerminalAuthority, + transferableSleepingAgentSession +} from './orcad-source-workspace-session-eligibility' +import { collectOwnedTerminalTabIds } from './orcad-source-workspace-session-layout' +import { + mergeSessionFragments, + sessionPartitions +} from './orcad-source-workspace-session-fragments' +import { + hasDuplicateOrcadMigrationScrollbackDescriptors, + projectOrcadMigrationSessionScrollback +} from './orcad-source-scrollback-state' + +export type OrcadMigrationSourceWorkspaceSessionInspection = { + payload: WorkspaceSessionState | undefined + snapshots: OrcadMigrationTerminalScrollbackSnapshot[] + blockedCount: number +} + +export function collectOrcadMigrationSourceWorkspaceSession( + state: PersistedState, + source: OrcadMigrationManifestSource, + catalog: OrcadMigrationCatalogPayload, + storage?: TerminalScrollbackSnapshotStorage +): OrcadMigrationSourceWorkspaceSessionInspection { + const scope = createOrcadMigrationSourceScope({ source, catalog }) + const fragments: WorkspaceSessionState[] = [] + const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] + let blockedCount = 0 + for (const [partitionId, session] of sessionPartitions(state, LOCAL_EXECUTION_HOST_ID)) { + const sourceHostPartition = partitionId === scope.hostId + const terminalTabIds = collectOwnedTerminalTabIds(session, scope) + blockedCount += countUnsupportedSessionState( + state, + session, + scope, + sourceHostPartition, + terminalTabIds + ) + blockedCount += countUnrepresentableMarkdownState(session, scope, sourceHostPartition) + const fragment = extractSessionOwnersForTransfer(session, { + mapOwnerKey: (ownerKey) => + orcadMigrationOwnerMatchesScope(ownerKey, scope) + ? unqualifyOrcadMigrationOwnerKey(ownerKey) + : null, + mapWorktreeId: unqualifyOrcadMigrationOwnerKey, + projectSessionFocus: sourceHostPartition + ? ({ source, transferred, terminalTabIds }) => + projectDormantSessionFocus(source, transferred, scope, terminalTabIds) + : undefined, + projectSleepingAgentSession: (record) => + transferableSleepingAgentSession(record, session, scope, terminalTabIds) + ? { + ...structuredClone(record), + worktreeId: unqualifyOrcadMigrationOwnerKey(record.worktreeId), + connectionId: null + } + : null + }) + // A shutdown marker is only a reconnect hint. Once the source has no live + // PTY authority for that worktree, carrying it would make the destination + // try to resurrect a process that no longer exists. + if (fragment.activeWorktreeIdsOnShutdown) { + fragment.activeWorktreeIdsOnShutdown = fragment.activeWorktreeIdsOnShutdown.filter( + (worktreeId) => + shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) + ) + if (fragment.activeWorktreeIdsOnShutdown.length === 0) { + delete fragment.activeWorktreeIdsOnShutdown + } + } + if (hasTransferredSessionState(fragment)) { + const projected = projectOrcadMigrationSessionScrollback( + projectSessionToDestination(fragment, scope), + storage + ) + blockedCount += projected.blockedCount + snapshots.push(...projected.snapshots) + fragments.push(projected.session) + } + } + if (hasDuplicateOrcadMigrationScrollbackDescriptors(snapshots)) { + blockedCount += 1 + } + if (blockedCount > 0 || fragments.length === 0) { + return { payload: undefined, snapshots: [], blockedCount } + } + const merged = mergeSessionFragments(fragments) + return merged + ? { payload: merged, snapshots, blockedCount: 0 } + : { payload: undefined, snapshots: [], blockedCount: 1 } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts new file mode 100644 index 00000000000..930bf5d1886 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts @@ -0,0 +1,122 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' +import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' +import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity' +import { + getExecutionHostIdFromWorktreeHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree-identity-metadata' +import { + createOrcadMigrationSourceScope, + orcadMigrationOwnerMatchesScope, + unqualifyOrcadMigrationOwnerKey, + type OrcadMigrationSourceScope +} from './orcad-source-scope' + +export function inspectOrcadSourceWorktreeMetadata( + state: PersistedState, + scope: OrcadMigrationSourceScope +) { + const rows: { sourceKey: string; meta: WorktreeMeta }[] = [] + let blockedCount = 0 + for (const [sourceKey, meta] of Object.entries(state.worktreeMeta)) { + const host = getExecutionHostIdFromWorktreeHostIdentity(sourceKey) + if ((host && host !== scope.hostId) || (meta.hostId && meta.hostId !== scope.hostId)) { + if (host === scope.hostId || meta.hostId === scope.hostId) { + blockedCount++ + } + continue + } + if (orcadMigrationOwnerMatchesScope(sourceKey, scope)) { + rows.push({ sourceKey, meta }) + } else if (meta.hostId === scope.hostId || host === scope.hostId) { + blockedCount++ + } + } + const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat()) + for (const [identity, meta] of Object.entries(state.worktreeMetaByIdentity ?? {})) { + if (meta.hostId === scope.hostId && !referenced.has(identity)) { + blockedCount++ + } + } + for (const [alias, identities] of Object.entries(state.worktreeIdentityAliases ?? {})) { + if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== scope.hostId) { + continue + } + const meta = identities.length === 1 ? state.worktreeMetaByIdentity?.[identities[0]] : undefined + const worktreeId = unqualifyOrcadMigrationOwnerKey(alias) + if ( + isWorktreeHostIdentity(worktreeId) || + !orcadMigrationOwnerMatchesScope(alias, scope) || + !meta || + !meta.instanceId || + (meta.hostId !== undefined && meta.hostId !== scope.hostId) || + identities[0] !== + canonicalWorktreeIdentity({ + worktreeId, + executionHostId: scope.hostId, + instanceId: meta.instanceId + }) + ) { + blockedCount++ + continue + } + const legacy = rows.filter( + (row) => unqualifyOrcadMigrationOwnerKey(row.sourceKey) === worktreeId + ) + if ( + legacy.length > 1 || + (legacy.length === 1 && + serializeOrcadMigrationValue({ ...legacy[0].meta, hostId: scope.hostId }) !== + serializeOrcadMigrationValue({ ...meta, hostId: scope.hostId })) + ) { + // Neither representation may silently discard data held only by its competing row. + blockedCount++ + continue + } + if (legacy.length === 0) { + rows.push({ sourceKey: alias, meta: { ...meta, hostId: scope.hostId } }) + } + } + return { rows, blockedCount } +} + +export function retireOrcadSourceWorktreeMetadata( + state: PersistedState, + manifest: OrcadMigrationManifest +) { + const scope = createOrcadMigrationSourceScope({ + source: manifest.source, + catalog: manifest.payload + }) + const entries = manifest.payload.dormantState?.worktreeMeta ?? [] + const worktreeIds = new Set(entries.map((entry) => entry.worktreeId)) + const removedIdentities = new Set() + for (const [alias, identities] of Object.entries(state.worktreeIdentityAliases ?? {})) { + if ( + getExecutionHostIdFromWorktreeHostIdentity(alias) === scope.hostId && + worktreeIds.has(unqualifyOrcadMigrationOwnerKey(alias)) + ) { + identities.forEach((identity) => removedIdentities.add(identity)) + delete state.worktreeIdentityAliases?.[alias] + } + } + entries.forEach((entry) => delete state.worktreeMeta[entry.sourceKey]) + pruneUnreferencedWorktreeIdentityMeta(state, removedIdentities) +} + +export function assertOrcadSourceWorktreeMetadataRetired( + state: PersistedState, + manifest: OrcadMigrationManifest +) { + const scope = createOrcadMigrationSourceScope({ + source: manifest.source, + catalog: manifest.payload + }) + const inspection = inspectOrcadSourceWorktreeMetadata(state, scope) + if (inspection.rows.length || inspection.blockedCount) { + throw new Error('orcad_migration_source_worktree_metadata_reappeared') + } +} diff --git a/src/main/ssh/orcad-migration-manifest-export.test.ts b/src/main/ssh/orcad-migration-manifest-export.test.ts new file mode 100644 index 00000000000..8637dca12b2 --- /dev/null +++ b/src/main/ssh/orcad-migration-manifest-export.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Repo } from '../../shared/repo-types' +import type { SshTarget } from '../../shared/ssh-types' +import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' +import { emptyDormantPayload } from '../persistence/migrating-orcad-catalog/orcad-source-dormant-state' +import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 7 +} + +function repo(id: string, connectionId: string, projectGroupId?: string): Repo { + return { + id, + path: `/srv/${id}`, + displayName: id, + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId, + ...(projectGroupId ? { projectGroupId } : {}) + } +} + +function group( + id: string, + connectionId: string | null, + parentGroupId: string | null = null +): ProjectGroup { + return { + id, + name: id, + parentPath: `/srv/${id}`, + connectionId, + parentGroupId, + createdFrom: 'manual', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +function folder(id: string, projectGroupId: string, connectionId?: string): FolderWorkspace { + return { + id, + projectGroupId, + name: id, + folderPath: `/srv/${id}`, + ...(connectionId ? { connectionId } : {}), + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } +} + +describe('orcad migration manifest export', () => { + it('exports only the target catalog plus referenced group ancestry', () => { + const groups = [ + group('parent', null), + group('repo-group', 'ssh-prod', 'parent'), + group('folder-group', 'ssh-prod'), + group('other-group', 'ssh-other') + ] + const manifest = createOrcadMigrationManifest( + { + collectOrcadMigrationSourceDormantState: emptyDormantPayload, + getRepos: () => [ + repo('repo-prod', 'ssh-prod', 'repo-group'), + repo('repo-other', 'ssh-other') + ], + getProjectGroups: () => groups, + getFolderWorkspaces: () => [ + folder('folder-inherited', 'folder-group'), + folder('folder-explicit', 'folder-group', 'ssh-prod'), + folder('folder-other', 'other-group', 'ssh-other') + ] + }, + TARGET, + { migrationId: 'migration-1', now: () => new Date('2026-08-30T12:00:00.000Z') } + ) + + expect(manifest.payload.repositories.map((entry) => entry.id)).toEqual(['repo-prod']) + expect(manifest.payload.projectGroups.map((entry) => entry.id)).toEqual([ + 'parent', + 'repo-group', + 'folder-group' + ]) + expect(manifest.payload.folderWorkspaces.map((entry) => entry.id)).toEqual([ + 'folder-inherited', + 'folder-explicit' + ]) + expect(manifest.source).toEqual({ + sshTargetId: 'ssh-prod', + sshTargetGeneration: 7, + targetLabel: 'Production' + }) + const { manifestSha256, ...unsigned } = manifest + expect(manifestSha256).toBe(computeOrcadMigrationManifestSha256(unsigned)) + }) + + it('records a null generation for a legacy registration without mutating it', () => { + const target = { ...TARGET, generation: undefined } + const manifest = createOrcadMigrationManifest( + { + collectOrcadMigrationSourceDormantState: emptyDormantPayload, + getRepos: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [] + }, + target, + { migrationId: 'migration-legacy', now: () => new Date('2026-08-30T12:00:00.000Z') } + ) + + expect(manifest.source.sshTargetGeneration).toBeNull() + expect(target.generation).toBeUndefined() + }) + + it('includes a session-only dormant payload', () => { + const dormant = emptyDormantPayload() + dormant.workspaceSession = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + 'repo-prod::/srv/worktree': [ + { + id: 'tab-dormant', + ptyId: null, + worktreeId: 'repo-prod::/srv/worktree', + title: 'Dormant', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + const manifest = createOrcadMigrationManifest( + { + collectOrcadMigrationSourceDormantState: () => dormant, + getRepos: () => [repo('repo-prod', TARGET.id)], + getProjectGroups: () => [], + getFolderWorkspaces: () => [] + }, + TARGET, + { migrationId: 'migration-session-only' } + ) + + expect(manifest.payload.dormantState?.workspaceSession?.tabsByWorktree).toHaveProperty( + 'repo-prod::/srv/worktree' + ) + }) +}) diff --git a/src/main/ssh/orcad-migration-manifest-export.ts b/src/main/ssh/orcad-migration-manifest-export.ts new file mode 100644 index 00000000000..a94c7610dab --- /dev/null +++ b/src/main/ssh/orcad-migration-manifest-export.ts @@ -0,0 +1,77 @@ +/** + * The signed manifest a relay-hosted SSH target's state is exported as. + * + * Reads only: the catalog rows the target owns and the dormant state that references them, copied + * out of the profile-state store. The source keeps every row; retiring it happens only after the + * destination has verified and imported this exact manifest. + */ +import { randomUUID } from 'node:crypto' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + parseOrcadMigrationManifest, + type OrcadMigrationDormantStatePayload, + type OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import type { SshTarget } from '../../shared/ssh-types' +import type { Store } from '../persistence' +import { collectOrcadMigrationSourceCatalog } from '../persistence/migrating-orcad-catalog/orcad-source-catalog' +import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' + +export type OrcadMigrationExportStore = Pick< + Store, + | 'collectOrcadMigrationSourceDormantState' + | 'getFolderWorkspaces' + | 'getProjectGroups' + | 'getRepos' +> + +export function createOrcadMigrationManifest( + store: OrcadMigrationExportStore, + target: SshTarget, + options: { migrationId?: string; now?: () => Date; destinationEnvironmentId?: string } = {} +): OrcadMigrationManifest { + const payload = collectOrcadMigrationSourceCatalog(store, target) + const source = { + sshTargetId: target.id, + sshTargetGeneration: target.generation ?? null, + targetLabel: target.label + } + const dormantState = store.collectOrcadMigrationSourceDormantState( + source, + payload, + options.destinationEnvironmentId + ) + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: options.migrationId ?? randomUUID(), + createdAt: (options.now ?? (() => new Date()))().toISOString(), + source, + payload: { + ...payload, + ...(hasDormantState(dormantState) ? { dormantState } : {}) + }, + ...(options.destinationEnvironmentId + ? { destinationEnvironmentId: options.destinationEnvironmentId } + : {}) + } + return parseOrcadMigrationManifest({ + ...unsigned, + manifestSha256: computeOrcadMigrationManifestSha256(unsigned) + }) +} + +function hasDormantState(state: OrcadMigrationDormantStatePayload): boolean { + return ( + state.worktreeMeta.length > 0 || + state.worktreeLineage.length > 0 || + state.workspaceLineage.length > 0 || + state.sparsePresets.length > 0 || + state.retiredWorktreeNames.length > 0 || + state.retiredWorktreeNamespaces.length > 0 || + state.workspaceSession !== undefined || + (state.terminalScrollbackSnapshots?.length ?? 0) > 0 || + (state.automations?.length ?? 0) > 0 || + (state.automationRuns?.length ?? 0) > 0 || + (state.clientState !== undefined && Object.keys(state.clientState).length > 0) + ) +} diff --git a/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts b/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts new file mode 100644 index 00000000000..2a1b44170e4 --- /dev/null +++ b/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + type OrcadMigrationCatalogState, + type OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../shared/orcad-migration-scrollback' +import { transferOrcadMigrationSnapshots } from './orcad-migration-snapshot-coordinator' + +const BYTES = Buffer.from('resume these bytes', 'utf8') +const SNAPSHOT: OrcadMigrationTerminalScrollbackSnapshot = { + tabId: 'tab-1', + leafId: 'leaf-1', + ref: `v1-${'1'.repeat(32)}`, + sha256: createHash('sha256').update(BYTES).digest('hex'), + byteLength: BYTES.length +} +const MANIFEST: OrcadMigrationManifest = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-1', + createdAt: '2026-08-30T12:00:00.000Z', + source: { sshTargetId: 'source', sshTargetGeneration: 1, targetLabel: 'Source' }, + payload: { + repositories: [], + projectGroups: [], + folderWorkspaces: [], + dormantState: { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + terminalScrollbackSnapshots: [SNAPSHOT] + } + }, + manifestSha256: 'a'.repeat(64) +} +type ChunkReader = ( + manifest: OrcadMigrationManifest, + ref: string, + offset: number +) => { bytesBase64: string; totalBytes: number; eof: boolean } + +function source(read: ChunkReader) { + return { + readOrcadMigrationSourceSnapshotChunk: read, + retainOrcadMigrationScrollback: vi.fn(), + releaseOrcadMigrationScrollback: vi.fn() + } +} + +const unreachableRead: ChunkReader = () => { + throw new Error('must not read') +} + +describe('orcad migration snapshot coordinator', () => { + it('resumes at the observed offset and reconciles a lost chunk response', async () => { + const initialOffset = 4 + const sourceRead = vi.fn(() => ({ + bytesBase64: BYTES.subarray(initialOffset).toString('base64'), + totalBytes: BYTES.length, + eof: true + })) + const store = source(sourceRead) + const snapshotRequest = vi.fn() + const remoteReads = [staged(BYTES.length), staged(BYTES.length)] + + await transferOrcadMigrationSnapshots({ + source: store, + manifest: MANIFEST, + state: staged(initialOffset), + destination: { + stageChunk: async (request) => { + snapshotRequest(request) + throw new Error('response lost') + }, + readState: async () => nextState(remoteReads) + } + }) + + expect(sourceRead).toHaveBeenCalledWith(MANIFEST, SNAPSHOT.ref, initialOffset) + // The bytes stay retained exactly for the transfer's duration. + expect(store.retainOrcadMigrationScrollback).toHaveBeenCalledWith(MANIFEST) + expect(store.releaseOrcadMigrationScrollback).toHaveBeenCalledWith(MANIFEST.migrationId) + expect(snapshotRequest).toHaveBeenCalledWith( + expect.objectContaining({ offset: initialOffset, ref: SNAPSHOT.ref }) + ) + expect(remoteReads).toEqual([]) + }) + + it('fails closed when an old host omits snapshot upload state', async () => { + const sourceRead = vi.fn(unreachableRead) + const store = source(sourceRead) + await expect( + transferOrcadMigrationSnapshots({ + source: store, + manifest: MANIFEST, + state: staged(), + destination: { + stageChunk: async () => { + throw new Error('must not upload') + }, + readState: async () => staged() + } + }) + ).rejects.toThrow('orcad_migration_snapshot_transfer_unsupported') + expect(sourceRead).not.toHaveBeenCalled() + // A failed transfer still releases what it retained. + expect(store.releaseOrcadMigrationScrollback).toHaveBeenCalledOnce() + }) + + it('refuses a snapshot whose source length changed since export', async () => { + await expect( + transferOrcadMigrationSnapshots({ + source: source(() => ({ + bytesBase64: Buffer.from('changed').toString('base64'), + totalBytes: BYTES.length + 1, + eof: true + })), + manifest: MANIFEST, + state: staged(0), + destination: { + stageChunk: async () => { + throw new Error('must not upload') + }, + readState: async () => staged(0) + } + }) + ).rejects.toThrow('orcad_migration_source_snapshot_changed') + }) + + it('requires complete upload evidence after the final chunk', async () => { + const remoteReads = [staged(BYTES.length - 1)] + await expect( + transferOrcadMigrationSnapshots({ + source: source(unreachableRead), + manifest: MANIFEST, + state: staged(BYTES.length), + destination: { + stageChunk: async () => { + throw new Error('must not upload') + }, + readState: async () => nextState(remoteReads) + } + }) + ).rejects.toThrow('orcad_migration_snapshot_transfer_incomplete') + }) +}) + +function staged(receivedBytes?: number): Extract { + return { + state: 'staged', + migrationId: MANIFEST.migrationId, + manifestSha256: MANIFEST.manifestSha256, + stagedAt: '2026-08-30T12:01:00.000Z', + ...(receivedBytes === undefined ? {} : { snapshotUploads: [{ ...SNAPSHOT, receivedBytes }] }) + } +} + +function nextState( + states: Extract[] +): Extract { + const state = states.shift() + if (!state) { + throw new Error('unexpected remote read') + } + return state +} diff --git a/src/main/ssh/orcad-migration-snapshot-coordinator.ts b/src/main/ssh/orcad-migration-snapshot-coordinator.ts new file mode 100644 index 00000000000..86e129f345f --- /dev/null +++ b/src/main/ssh/orcad-migration-snapshot-coordinator.ts @@ -0,0 +1,124 @@ +/** + * Sending a manifest's scrollback snapshots to the destination in bounded, resumable chunks. + * + * The source side only reads: each chunk comes from the profile-state store, checked against the + * signed manifest's length and digest. The destination's catalog operations are passed in, so + * this driver owns no transport and retires nothing. A chunk whose acknowledgement was lost is + * confirmed by reading the destination's recorded offset, never assumed. + */ +import type { + OrcadMigrationCatalogState, + OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import { + decodeOrcadMigrationSnapshotChunk, + type OrcadMigrationSnapshotChunkRequest, + type OrcadMigrationSnapshotChunkResult +} from '../../shared/orcad-migration-scrollback' +import type { Store } from '../persistence' + +export type OrcadMigrationSnapshotSource = Pick< + Store, + | 'readOrcadMigrationSourceSnapshotChunk' + | 'retainOrcadMigrationScrollback' + | 'releaseOrcadMigrationScrollback' +> + +export type OrcadMigrationSnapshotDestination = { + readState: (manifest: OrcadMigrationManifest) => Promise + stageChunk: ( + request: OrcadMigrationSnapshotChunkRequest + ) => Promise +} + +export async function transferOrcadMigrationSnapshots(args: { + source: OrcadMigrationSnapshotSource + manifest: OrcadMigrationManifest + /** The destination's staged state, whose upload offsets say where each snapshot resumes. */ + state: Extract + destination: OrcadMigrationSnapshotDestination +}): Promise { + const snapshots = args.manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] + if (snapshots.length === 0) { + return + } + // Closing a tab mid-transfer must not delete the bytes this manifest promised. + args.source.retainOrcadMigrationScrollback(args.manifest) + try { + await sendSnapshots(args, snapshots) + } finally { + args.source.releaseOrcadMigrationScrollback(args.manifest.migrationId) + } +} + +async function sendSnapshots( + args: Parameters[0], + snapshots: NonNullable< + NonNullable['terminalScrollbackSnapshots'] + > +): Promise { + const offsets = new Map( + (args.state.snapshotUploads ?? []).map((entry) => [entry.ref, entry.receivedBytes]) + ) + for (const snapshot of snapshots) { + let offset = offsets.get(snapshot.ref) + if (offset === undefined) { + throw new Error('orcad_migration_snapshot_transfer_unsupported') + } + while (offset < snapshot.byteLength) { + const chunk = args.source.readOrcadMigrationSourceSnapshotChunk( + args.manifest, + snapshot.ref, + offset + ) + if (chunk.totalBytes !== snapshot.byteLength || !chunk.bytesBase64) { + throw new Error('orcad_migration_source_snapshot_changed') + } + const request: OrcadMigrationSnapshotChunkRequest = { + migrationId: args.manifest.migrationId, + manifestSha256: args.manifest.manifestSha256, + ref: snapshot.ref, + offset, + bytesBase64: chunk.bytesBase64 + } + const expectedOffset = offset + decodeOrcadMigrationSnapshotChunk(chunk.bytesBase64).length + offset = await stageChunkWithRecovery(args, request, expectedOffset) + } + } + const verified = await args.destination.readState(args.manifest) + if ( + verified.state !== 'staged' || + (verified.snapshotUploads ?? []).length !== snapshots.length || + (verified.snapshotUploads ?? []).some((entry) => entry.receivedBytes !== entry.byteLength) + ) { + throw new Error('orcad_migration_snapshot_transfer_incomplete') + } +} + +async function stageChunkWithRecovery( + args: { manifest: OrcadMigrationManifest; destination: OrcadMigrationSnapshotDestination }, + request: OrcadMigrationSnapshotChunkRequest, + expectedOffset: number +): Promise { + try { + const result = await args.destination.stageChunk(request) + if (result.acknowledgedOffset !== expectedOffset) { + throw new Error('orcad_migration_snapshot_ack_invalid') + } + return result.acknowledgedOffset + } catch (error) { + try { + const observed = await args.destination.readState(args.manifest) + const received = + observed.state === 'staged' + ? observed.snapshotUploads?.find((entry) => entry.ref === request.ref)?.receivedBytes + : undefined + if (received === expectedOffset) { + return received + } + } catch { + // The chunk stays unverifiable; report the first failure. + } + throw error + } +} diff --git a/src/main/ssh/ssh-target-orcad-claims.ts b/src/main/ssh/ssh-target-orcad-claims.ts index fc389d3c44f..cfce055a9ee 100644 --- a/src/main/ssh/ssh-target-orcad-claims.ts +++ b/src/main/ssh/ssh-target-orcad-claims.ts @@ -107,6 +107,17 @@ export class SshTargetOrcadClaims { } function collectEmptyTargetBlockers(store: ClaimStore, target: SshTarget): OrcadMigrationBlocker[] { + return [ + ...collectTargetCatalogBlockers(store, target), + ...collectDependentStateBlockers(store, target.id) + ] +} + +/** Ownership, the catalog rows the target owns, and its saved port forwards. */ +export function collectTargetCatalogBlockers( + store: Pick, + target: SshTarget +): OrcadMigrationBlocker[] { const blockers: OrcadMigrationBlocker[] = [] if (target.owner) { blockers.push({ @@ -144,7 +155,6 @@ function collectEmptyTargetBlockers(store: ClaimStore, target: SshTarget): Orcad portForwards: target.portForwards.map((portForward) => ({ ...portForward })) }) } - blockers.push(...collectDependentStateBlockers(store, target.id)) return blockers } diff --git a/src/main/ssh/ssh-target-orcad-dependents.ts b/src/main/ssh/ssh-target-orcad-dependents.ts index bfac28d25ce..9845bb46a80 100644 --- a/src/main/ssh/ssh-target-orcad-dependents.ts +++ b/src/main/ssh/ssh-target-orcad-dependents.ts @@ -5,11 +5,13 @@ import type { Store } from '../persistence' import { getDefaultWorkspaceSession } from '../../shared/constants' import { toSshExecutionHostId } from '../../shared/execution-host' -import type { - OrcadMigrationBlocker, - OrcadMigrationDependency, - OrcadMigrationDependencyKind +import { + ORCAD_MIGRATION_DEPENDENCY_KINDS, + type OrcadMigrationBlocker, + type OrcadMigrationDependency, + type OrcadMigrationDependencyKind } from '../../shared/orcad-migration-preflight' +import type { OrcadMigrationManifest } from '../../shared/orcad-migration-manifest' export type DependentStateStore = Pick< Store, @@ -76,6 +78,40 @@ export function collectDependentStateBlockers( return blockers } +/** Kinds with their own blockers (port forwards, terminal leases) are counted elsewhere. */ +const CENSUS_DEPENDENCY_KINDS = ORCAD_MIGRATION_DEPENDENCY_KINDS.filter( + (kind) => kind !== 'saved-port-forward' && kind !== 'terminal-lease' +) + +/** + * The export-aware census: only state that references the target and that this manifest cannot + * carry blocks. A census the store could not take is unverifiable, never an empty one. + */ +export function collectUntransferredDependentBlockers( + store: Pick, + manifest: OrcadMigrationManifest +): OrcadMigrationBlocker[] { + let counts: Record + try { + counts = store.inspectOrcadMigrationUntransferredDependencies(manifest).counts + } catch { + return [ + { + code: 'orcad_migration_dependency_unverifiable', + category: 'live-or-unverifiable', + sources: [...CENSUS_DEPENDENCY_KINDS] + } + ] + } + const dependencies = CENSUS_DEPENDENCY_KINDS.filter((kind) => counts[kind] > 0).map((kind) => ({ + kind, + count: counts[kind] + })) + return dependencies.length > 0 + ? [{ code: 'orcad_migration_dependent_state', category: 'client-owned-state', dependencies }] + : [] +} + /** Non-default fields of the host's session partition, plus a local session pointed at the host. */ function workspaceSessionReferences(store: DependentStateStore, hostId: string): string[] { const references: string[] = [] diff --git a/src/main/ssh/ssh-target-orcad-preflight.test.ts b/src/main/ssh/ssh-target-orcad-preflight.test.ts new file mode 100644 index 00000000000..65905d9a50c --- /dev/null +++ b/src/main/ssh/ssh-target-orcad-preflight.test.ts @@ -0,0 +1,159 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { SshRemotePtyLease, SshTarget } from '../../shared/ssh-types' +import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { Store } from '../persistence/loading-store/store' +import { + preflightOrcadMigrationExport, + type OrcadMigrationPreflightStore +} from './ssh-target-orcad-preflight' + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 2 +} + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function preflightStore( + configure: (store: Store) => void, + overrides: Partial = {} +): OrcadMigrationPreflightStore { + const directory = mkdtempSync(join(tmpdir(), 'orcad-export-preflight-')) + directories.push(directory) + const store = createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) + configure(store) + return { + getSshTarget: (id) => store.getSshTarget(id), + getSshRemotePtyLeases: (id) => store.getSshRemotePtyLeases(id), + getRepos: () => store.getRepos(), + getFolderWorkspaces: () => store.getFolderWorkspaces(), + getProjectGroups: () => store.getProjectGroups(), + collectOrcadMigrationSourceDormantState: (...args) => + store.collectOrcadMigrationSourceDormantState(...args), + inspectOrcadMigrationUntransferredDependencies: (manifest) => + store.inspectOrcadMigrationUntransferredDependencies(manifest), + ...overrides + } +} + +function withRepo(store: Store): void { + store.addSshTarget(TARGET) + store.addRepo({ + id: 'repo-1', + path: '/srv/app', + displayName: 'App', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId: TARGET.id + }) +} + +describe('migration export preflight', () => { + it('drains the catalog the target owns instead of blocking on it', () => { + const result = preflightOrcadMigrationExport(preflightStore(withRepo), TARGET.id) + expect(result.claimable).toBe(true) + expect(result.blockers.map((blocker) => blocker.code)).toEqual([ + 'orcad_migration_direct_ssh_repositories' + ]) + }) + + it('blocks on a live relay terminal, which cannot move', () => { + const lease: SshRemotePtyLease = { + targetId: TARGET.id, + ptyId: 'pty-1', + state: 'attached', + createdAt: 1, + updatedAt: 1 + } + const result = preflightOrcadMigrationExport( + preflightStore(withRepo, { getSshRemotePtyLeases: () => [lease] }), + TARGET.id + ) + expect(result.claimable).toBe(false) + expect(result.blockers.map((blocker) => blocker.code)).toContain( + 'orcad_migration_direct_ssh_terminal_leases' + ) + }) + + it('blocks on dependent state the manifest cannot carry', () => { + const result = preflightOrcadMigrationExport( + preflightStore(withRepo, { + inspectOrcadMigrationUntransferredDependencies: () => ({ + totalCount: 2, + counts: { + automation: 2, + 'automation-run': 0, + 'mobile-tab-selection': 0, + 'retired-worktree-name': 0, + 'saved-port-forward': 0, + 'sparse-preset': 0, + 'terminal-lease': 0, + 'terminal-recovery': 0, + 'ui-routing': 0, + 'workspace-lineage': 0, + 'workspace-session': 0, + 'worktree-lineage': 0, + 'worktree-metadata': 0 + } + }) + }), + TARGET.id + ) + expect(result.claimable).toBe(false) + expect(result.blockers).toContainEqual({ + code: 'orcad_migration_dependent_state', + category: 'client-owned-state', + dependencies: [{ kind: 'automation', count: 2 }] + }) + }) + + it('treats a census the store could not take as unverifiable, never empty', () => { + const result = preflightOrcadMigrationExport( + preflightStore(withRepo, { + inspectOrcadMigrationUntransferredDependencies: () => { + throw new Error('state unreadable') + } + }), + TARGET.id + ) + expect(result.claimable).toBe(false) + expect(result.blockers.map((blocker) => blocker.code)).toContain( + 'orcad_migration_dependency_unverifiable' + ) + }) + + it('refuses a target another runtime owns, and passes the owner its own claim', () => { + const owned = preflightStore((store) => + store.addSshTarget({ ...TARGET, owner: createManagedOrcadSshOwner('env-1') }) + ) + expect(preflightOrcadMigrationExport(owned, TARGET.id).claimable).toBe(false) + expect(preflightOrcadMigrationExport(owned, TARGET.id, 'env-1').claimable).toBe(true) + }) + + it('reports an unknown target', () => { + expect( + preflightOrcadMigrationExport( + preflightStore(() => {}), + 'missing' + ) + ).toMatchObject({ + claimable: false, + blockers: [{ code: 'orcad_migration_target_not_found' }] + }) + }) +}) diff --git a/src/main/ssh/ssh-target-orcad-preflight.ts b/src/main/ssh/ssh-target-orcad-preflight.ts new file mode 100644 index 00000000000..14a2bce0270 --- /dev/null +++ b/src/main/ssh/ssh-target-orcad-preflight.ts @@ -0,0 +1,79 @@ +/** + * Can this relay-hosted SSH target's state be exported to a managed orcad? + * + * Unlike claiming an empty target, export carries the target's repositories, folder + * workspaces and the dormant state the manifest can represent, so those drain rather than block. + * What blocks is what cannot move: another owner, live terminal leases, and dependent state the + * manifest cannot carry. Read-only: building the manifest here exports nothing. + */ +import type { Store } from '../persistence' +import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import type { + OrcadMigrationBlocker, + OrcadMigrationPreflight +} from '../../shared/orcad-migration-preflight' +import { + createOrcadMigrationManifest, + type OrcadMigrationExportStore +} from './orcad-migration-manifest-export' +import { collectTargetCatalogBlockers } from './ssh-target-orcad-claims' +import { collectUntransferredDependentBlockers } from './ssh-target-orcad-dependents' + +export type OrcadMigrationPreflightStore = OrcadMigrationExportStore & + Pick< + Store, + 'getSshTarget' | 'getSshRemotePtyLeases' | 'inspectOrcadMigrationUntransferredDependencies' + > + +export function preflightOrcadMigrationExport( + store: OrcadMigrationPreflightStore, + targetId: string, + environmentId?: string +): OrcadMigrationPreflight { + const target = store.getSshTarget(targetId) + if (!target) { + return { + targetId, + targetLabel: null, + claimable: false, + blockers: [{ code: 'orcad_migration_target_not_found', category: 'registration' }] + } + } + if (environmentId && getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId) { + return { targetId, targetLabel: target.label, claimable: true, blockers: [] } + } + const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)] + const terminalLeases = store + .getSshRemotePtyLeases(targetId) + .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') + .map(({ ptyId, worktreeId, tabId, leafId, state, updatedAt }) => ({ + ptyId, + worktreeId, + tabId, + leafId, + state, + updatedAt + })) + if (terminalLeases.length > 0) { + // A relay PTY cannot move to orcad; its work must finish first. + blockers.push({ + code: 'orcad_migration_direct_ssh_terminal_leases', + category: 'live-or-unverifiable', + terminalLeases + }) + } + blockers.push( + ...collectUntransferredDependentBlockers(store, createOrcadMigrationManifest(store, target)) + ) + return { + targetId, + targetLabel: target.label, + // Exported catalog rows and saved port forwards (which stay with the source) do not block. + claimable: blockers.every( + (blocker) => + blocker.category === 'drainable-static-state' || + blocker.code === 'orcad_migration_saved_port_forwards' + ), + blockers + } +} diff --git a/src/main/terminal-scrollback-snapshot-async-migration.ts b/src/main/terminal-scrollback-snapshot-async-migration.ts index 1768c0a20a1..6916c8de17a 100644 --- a/src/main/terminal-scrollback-snapshot-async-migration.ts +++ b/src/main/terminal-scrollback-snapshot-async-migration.ts @@ -40,7 +40,8 @@ export async function migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( export async function deleteRemovedTerminalScrollbackSnapshotsAsync( prior: WorkspaceSessionState | undefined, next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage + storage?: TerminalScrollbackSnapshotStorage, + retainedRefs: ReadonlySet = new Set() ): Promise { if (!prior) { return @@ -48,7 +49,7 @@ export async function deleteRemovedTerminalScrollbackSnapshotsAsync( const nextRefs = collectTerminalScrollbackSnapshotRefs(next) await Promise.all( [...collectTerminalScrollbackSnapshotRefs(prior)] - .filter((ref) => !nextRefs.has(ref)) + .filter((ref) => !nextRefs.has(ref) && !retainedRefs.has(ref)) .map((ref) => deleteTerminalScrollbackSnapshot(ref, storage)) ) } diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 70d4be18002..352edb5ebb4 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -3,6 +3,7 @@ import { closeSync, mkdirSync, openSync, + readFileSync, readSync, renameSync, rmSync, @@ -34,7 +35,9 @@ export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): stri return join(dirname(dataFile), SNAPSHOT_DIR_NAME) } -function getSnapshotRoot(storage?: TerminalScrollbackSnapshotStorage): string { +export function getTerminalScrollbackSnapshotRoot( + storage?: TerminalScrollbackSnapshotStorage +): string { return storage?.snapshotRoot ?? getLegacySnapshotRoot() } @@ -51,7 +54,7 @@ function snapshotPath(ref: string, snapshotRoot: string): string | null { } function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStorage): string[] { - const primaryRoot = getSnapshotRoot(storage) + const primaryRoot = getTerminalScrollbackSnapshotRoot(storage) const primaryPath = snapshotPath(ref, primaryRoot) if (!primaryPath) { return [] @@ -64,6 +67,32 @@ function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStor return fallbackPath ? [primaryPath, fallbackPath] : [primaryPath] } +export function getTerminalScrollbackSnapshotPath( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): string | null { + return snapshotPath(ref, getTerminalScrollbackSnapshotRoot(storage)) +} + +/** The stored bytes for `ref`, bounded by the store limit; `null` when absent or out of bounds. */ +export function readTerminalScrollbackStoredBytesSync( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): Buffer | null { + for (const path of snapshotReadPaths(ref, storage)) { + try { + const size = statSync(path).size + if (size <= 0 || size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { + return null + } + return readFileSync(path) + } catch { + // Try the profile fallback when the primary snapshot is absent. + } + } + return null +} + function trailingUtf8Bytes(value: string, maxBytes: number): Buffer { const bytes = Buffer.from(value, 'utf-8') if (bytes.length <= maxBytes) { @@ -106,7 +135,7 @@ export function writeTerminalScrollbackSnapshotSync(args: { return null } const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) - const snapshotRoot = getSnapshotRoot(args.storage) + const snapshotRoot = getTerminalScrollbackSnapshotRoot(args.storage) const path = snapshotPath(ref, snapshotRoot) if (!path) { return null @@ -144,7 +173,7 @@ export async function writeTerminalScrollbackSnapshot(args: { return null } const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) - const snapshotRoot = getSnapshotRoot(args.storage) + const snapshotRoot = getTerminalScrollbackSnapshotRoot(args.storage) const path = snapshotPath(ref, snapshotRoot) if (!path) { return null diff --git a/src/main/worktree-retirement-namespace.ts b/src/main/worktree-retirement-namespace.ts index 11abc8a7c1b..7f45ed72e92 100644 --- a/src/main/worktree-retirement-namespace.ts +++ b/src/main/worktree-retirement-namespace.ts @@ -50,7 +50,7 @@ export function retirementNamespaceKey(hostIdentity: string, probePath: string): /** Rewrites a key's host identity, keeping its workspace-path half. Returns null when the key is * not under `fromIdentity` or the swap is a no-op. */ -function swapRetirementNamespaceHost( +export function swapRetirementNamespaceHost( namespaceKey: string, fromIdentity: string, toIdentity: string diff --git a/src/shared/workspace-session-terminal-buffers.test.ts b/src/shared/workspace-session-terminal-buffers.test.ts index 5eeaf5c9894..6c77d18271d 100644 --- a/src/shared/workspace-session-terminal-buffers.test.ts +++ b/src/shared/workspace-session-terminal-buffers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from './constants' import type { WorkspaceSessionState } from './workspace-session-state-types' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from './terminal-scrollback-limits' import { getUtf8ByteLength } from './utf8-byte-limits' @@ -150,6 +150,40 @@ describe('pruneLocalTerminalScrollbackBuffers', () => { }) }) + it('keeps a dormant tab buffer, which has no live PTY to replay from', () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + 'local-repo::/w': [ + { + id: 'dormant-tab', + ptyId: null, + worktreeId: 'local-repo::/w', + title: 'Shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'dormant-tab': { + root: null, + activeLeafId: null, + expandedLeafId: null, + buffersByLeafId: { 'pane:1': 'dormant output' } + } + } + } + const result = pruneLocalTerminalScrollbackBuffers(session, [ + { id: 'local-repo', connectionId: null } + ]) + expect(result.terminalLayoutsByTabId['dormant-tab'].buffersByLeafId).toEqual({ + 'pane:1': 'dormant output' + }) + }) + it('drops scrollback for explicitly local execution hosts', () => { const result = pruneLocalTerminalScrollbackBuffers(makeRuntimeSession(), [ { diff --git a/src/shared/workspace-session-terminal-buffers.ts b/src/shared/workspace-session-terminal-buffers.ts index fa4dd0e095b..c7af0fc5b6e 100644 --- a/src/shared/workspace-session-terminal-buffers.ts +++ b/src/shared/workspace-session-terminal-buffers.ts @@ -91,19 +91,24 @@ export function pruneLocalTerminalScrollbackBuffers( repos: readonly RepoConnection[] ): WorkspaceSessionState { let repoById: Map | null = null - let worktreeIdByTabId: Map | null = null + let tabById: Map | null = null const tabsByWorktree = session.tabsByWorktree ?? {} const preservesScrollback = (tabId: string): boolean => { repoById ??= new Map(repos.map((repo) => [repo.id, repo] as const)) - if (!worktreeIdByTabId) { - worktreeIdByTabId = new Map() + if (!tabById) { + tabById = new Map() for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) + tabById.set(tab.id, { worktreeId, ptyId: tab.ptyId }) } } } - return shouldPreserveTerminalScrollbackBuffersForRepoMap(worktreeIdByTabId.get(tabId), repoById) + const tab = tabById.get(tabId) + // A dormant tab has no live PTY to replay from, so its saved buffer is the only copy. + return ( + tab?.ptyId === null || + shouldPreserveTerminalScrollbackBuffersForRepoMap(tab?.worktreeId, repoById) + ) } const terminalLayoutsByTabIdForRead = session.terminalLayoutsByTabId ?? {} From 0b7b9a9af5cf37028eb3ba904489aac8e3f7cffe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:08:41 -0700 Subject: [PATCH 05/26] test: isolate session fixtures and wait for completed indexing (#24544) --- .../session-scanner-codex-workers.test.ts | 28 ++------------- .../session-scanner-service-search.test.ts | 4 +-- .../orchestration-cli-subprocess.test.ts | 13 +++++-- .../e2e/e2e-worker-env-isolation.unit.test.ts | 35 +++++++++++++++++-- 4 files changed, 48 insertions(+), 32 deletions(-) diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 4d001d00422..d8b9189532e 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -11,10 +12,6 @@ afterEach(async () => { tempRoots = [] }) -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} - describe('scanAiVaultSessions Codex worker sessions', () => { it('hides Codex worker transcripts from session history', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-workers-')) @@ -198,28 +195,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { ) const result = await scanAiVaultSessions({ - claudeProjectsDir: join(root, 'claude-projects'), - codexSessionsDir, - geminiSessionsDir: join(root, 'gemini-sessions'), - antigravityBrainDir: join(root, 'antigravity-brain'), - copilotSessionsDir: join(root, 'copilot-sessions'), - cursorProjectsDir: join(root, 'cursor-projects'), - opencodeStorageDir: join(root, 'opencode-storage'), - opencodeDbPaths: [], - grokSessionsDir: join(root, 'grok-sessions'), - devinTranscriptsDir: join(root, 'devin-transcripts'), - hermesSessionsDir: join(root, 'hermes-sessions'), - rovoSessionsDir: join(root, 'rovo-sessions'), - openclawStateDir: join(root, 'openclaw-state'), - openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), - piSessionsDir: join(root, 'pi-sessions'), - ompSessionsDir: join(root, 'omp-sessions'), - primeAgentSessionsDir: join(root, 'prime-agent-sessions'), - droidSessionsDir: join(root, 'droid-sessions'), - droidProjectsDir: join(root, 'droid-projects'), - kimiSessionsDir: join(root, 'kimi-sessions'), - museSessionsDir: join(root, 'muse-sessions'), - zcodeDbPath: join(root, 'zcode-db.sqlite'), + ...isolatedScanRoots(root), platform: 'darwin' }) diff --git a/src/main/ai-vault/session-scanner-service-search.test.ts b/src/main/ai-vault/session-scanner-service-search.test.ts index 4cd2723a198..2508556d47e 100644 --- a/src/main/ai-vault/session-scanner-service-search.test.ts +++ b/src/main/ai-vault/session-scanner-service-search.test.ts @@ -117,11 +117,11 @@ it('reports the indexer phase and a live generation over the protocol', async () const status = await vi.waitFor(async () => { const value = await searchStatus() expect(value.filesIndexed).toBeGreaterThan(0) + expect(value.phase).toBe('current') + expect(value.generation).toBeGreaterThan(0) return value }) expect(status.enabled).toBe(true) - expect(status.phase).toBe('current') - expect(status.generation).toBeGreaterThan(0) expect(existsSync(harness.databasePath)).toBe(true) }) diff --git a/src/main/runtime/orchestration-cli-subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts index d8306f9399d..4f10ccba7e1 100644 --- a/src/main/runtime/orchestration-cli-subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -71,6 +71,14 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { const runtime = new OrcaRuntimeService() const db = new OrchestrationDb(':memory:') runtime.setOrchestrationDb(db) + // A consuming inbox check requires a live pane, even when no messages exist. + const paneKey = 'tab_keepalive:11111111-1111-4111-8111-111111111111' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_nobody' ? paneKey : null + ) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + runtime.getTerminalPaneKey(handle) + ) const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) await server.start() @@ -120,10 +128,9 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { child.once('error', rejectExit) }) - expect(exitCode).toBe(0) - const stderr = stderrChunks.map((c) => c.data).join('') const stdout = stdoutChunks.map((c) => c.data).join('') + expect(exitCode, stderr).toBe(0) const keepaliveLines = stderr .split('\n') @@ -188,6 +195,8 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { } finally { db.close() await server.stop() + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) } }, 30_000) }) diff --git a/tests/e2e/e2e-worker-env-isolation.unit.test.ts b/tests/e2e/e2e-worker-env-isolation.unit.test.ts index 9973230456d..69e0094f8b9 100644 --- a/tests/e2e/e2e-worker-env-isolation.unit.test.ts +++ b/tests/e2e/e2e-worker-env-isolation.unit.test.ts @@ -1,4 +1,13 @@ -import { readFileSync, readdirSync, statSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' @@ -26,7 +35,14 @@ const MODULE_SCOPE_ENV_WRITE = // No file may write at module scope. The replacement is a fixture option, which reaches the app // launch without touching the worker every other spec shares. const SCANNED_EXTENSIONS = ['.ts', '.tsx'] -const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '__fixtures__']) +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '__fixtures__', + '.cross-version-checkouts' +]) function collectE2eFiles(root: string): string[] { const found: string[] = [] @@ -61,6 +77,21 @@ describe('e2e worker env isolation', () => { expect(offenders).toEqual([]) }) + it('checks current source while excluding extracted release copies', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-e2e-env-scan-')) + try { + const cached = join(root, '.cross-version-checkouts') + mkdirSync(cached) + writeFileSync(join(cached, 'old.ts'), "process.env.ORCA_E2E_X = '1'\n") + const source = join(root, 'current.ts') + writeFileSync(source, "process.env.ORCA_E2E_X = '1'\n") + expect(collectE2eFiles(root)).toEqual([source]) + expect(findModuleScopeEnvWrites(source)).toHaveLength(1) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('detects the shape it is meant to catch', () => { // Guards the regex itself: a green that cannot go red would pass this whole file forever. expect(MODULE_SCOPE_ENV_WRITE.test("process.env.ORCA_E2E_X ??= '1'")).toBe(true) From 1fbfb13e0f69f9ab442921d0f344af71634a23f8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:31:24 -0700 Subject: [PATCH 06/26] test: isolate seeded Git repositories per Playwright worker (#24550) --- .../global-setup-cli-artifact.unit.test.ts | 14 +++++++- tests/e2e/global-setup.ts | 19 ++++++++-- tests/e2e/global-teardown.ts | 36 ++++++++++++++----- tests/e2e/global-teardown.unit.test.ts | 33 ++++++++++++++++- tests/e2e/helpers/orca-app.ts | 27 ++++++-------- tests/e2e/helpers/seeded-test-repo.ts | 28 ++++++++++++++- 6 files changed, 127 insertions(+), 30 deletions(-) diff --git a/tests/e2e/global-setup-cli-artifact.unit.test.ts b/tests/e2e/global-setup-cli-artifact.unit.test.ts index 4c22d1a219d..292b64653fd 100644 --- a/tests/e2e/global-setup-cli-artifact.unit.test.ts +++ b/tests/e2e/global-setup-cli-artifact.unit.test.ts @@ -17,7 +17,7 @@ vi.mock('node:fs', () => ({ })) vi.mock('./helpers/docker-ssh-relay-image', () => ({ prepareDockerSshRelayImage: vi.fn() })) -import globalSetup from './global-setup' +import globalSetup, { workerTestRepositoryPathFile } from './global-setup' beforeEach(() => { vi.resetAllMocks() @@ -42,6 +42,18 @@ function commands(): string[] { } describe('E2E shared CLI artifact', () => { + it('keeps worker publications separate from the run seed and from other workers', () => { + const runPath = path.join('temporary', 'run.txt') + expect(workerTestRepositoryPathFile(runPath)).toBe(runPath) + expect(workerTestRepositoryPathFile(runPath, '0')).not.toBe(runPath) + expect(workerTestRepositoryPathFile(runPath, '0')).not.toBe( + workerTestRepositoryPathFile(runPath, '1') + ) + expect(() => workerTestRepositoryPathFile(runPath, '../other')).toThrow( + 'Invalid Playwright worker index' + ) + }) + it('repairs downloaded permissions and installs the local launcher without recompiling', () => { vi.stubEnv('SKIP_BUILD', '1') globalSetup() diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 210c713159b..60a4642e4d6 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -18,10 +18,25 @@ import os from 'node:os' import { prepareDockerSshRelayImage } from './helpers/docker-ssh-relay-image' export const E2E_TEST_REPO_PATH_FILE_ENV = 'ORCA_E2E_TEST_REPO_PATH_FILE' -/** Temp file where the test repo path is stored for the fixture to read. */ -export const TEST_REPO_PATH_FILE = +const RUN_TEST_REPO_PATH_FILE = process.env[E2E_TEST_REPO_PATH_FILE_ENV] ?? path.join(os.tmpdir(), `orca-e2e-test-repo-path-${randomUUID()}.txt`) + +export function workerTestRepositoryPathFile(runPathFile: string, workerIndex?: string): string { + if (workerIndex === undefined) { + return runPathFile + } + if (!/^\d+$/.test(workerIndex)) { + throw new Error('Invalid Playwright worker index') + } + return `${runPathFile}.worker-${workerIndex}` +} + +// Restart helpers and the app fixture must read the same worker-owned repository. +export const TEST_REPO_PATH_FILE = workerTestRepositoryPathFile( + RUN_TEST_REPO_PATH_FILE, + process.env.TEST_WORKER_INDEX +) const ELECTRON_E2E_BUILD_TIMEOUT_MS = 300_000 const CLI_E2E_BUILD_TIMEOUT_MS = 120_000 const WEB_E2E_BUILD_TIMEOUT_MS = 300_000 diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 0961eb0ee5c..d33a5e87553 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -6,7 +6,8 @@ */ import { execFileSync } from 'node:child_process' -import { readFileSync, existsSync, realpathSync, rmSync } from 'node:fs' +import { readFileSync, readdirSync, existsSync, realpathSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' import { TEST_REPO_PATH_FILE } from './global-setup' export function linkedWorktreePaths(testRepoDir: string): string[] { @@ -47,16 +48,33 @@ export function cleanupTestRepository(testRepoDir: string): void { rmSync(root, { recursive: true, force: true }) } -export default function globalTeardown(): void { - if (!existsSync(TEST_REPO_PATH_FILE)) { +export function cleanupTestRepositoryPathFiles(runPathFile: string): void { + const directory = dirname(runPathFile) + if (!existsSync(directory)) { return } - - const testRepoDir = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() - if (testRepoDir && existsSync(testRepoDir)) { - cleanupTestRepository(testRepoDir) - console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) + const workerPrefix = `${basename(runPathFile)}.worker-` + const workerPathFiles = readdirSync(directory, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.startsWith(workerPrefix) && + /^\d+$/.test(entry.name.slice(workerPrefix.length)) + ) + .map((entry) => join(directory, entry.name)) + for (const pathFile of [runPathFile, ...workerPathFiles]) { + if (!existsSync(pathFile)) { + continue + } + const testRepoDir = readFileSync(pathFile, 'utf-8').trim() + if (testRepoDir && existsSync(testRepoDir)) { + cleanupTestRepository(testRepoDir) + console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) + } + rmSync(pathFile, { force: true }) } +} - rmSync(TEST_REPO_PATH_FILE, { force: true }) +export default function globalTeardown(): void { + cleanupTestRepositoryPathFiles(TEST_REPO_PATH_FILE) } diff --git a/tests/e2e/global-teardown.unit.test.ts b/tests/e2e/global-teardown.unit.test.ts index fde77199434..ec2a58a3ef3 100644 --- a/tests/e2e/global-teardown.unit.test.ts +++ b/tests/e2e/global-teardown.unit.test.ts @@ -3,7 +3,11 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { cleanupTestRepository, linkedWorktreePaths } from './global-teardown' +import { + cleanupTestRepository, + cleanupTestRepositoryPathFiles, + linkedWorktreePaths +} from './global-teardown' const roots: string[] = [] @@ -18,6 +22,33 @@ afterEach(() => { }) describe('E2E global teardown ownership', () => { + it('cleans orphaned worker publications while preserving another run', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-worker-teardown-')) + roots.push(root) + const runPathFile = path.join(root, 'run.txt') + const ownedRepositories = ['seed', 'worker-0', 'worker-3'].map((name) => { + const repository = path.join(root, name) + mkdirSync(repository) + git(repository, ['init']) + return repository + }) + const publications = [runPathFile, `${runPathFile}.worker-0`, `${runPathFile}.worker-3`] + publications.forEach((publication, index) => { + writeFileSync(publication, ownedRepositories[index]!) + }) + const unrelatedRepo = path.join(root, 'unrelated') + mkdirSync(unrelatedRepo) + const unrelatedPublication = path.join(root, 'another-run.txt.worker-0') + writeFileSync(unrelatedPublication, unrelatedRepo) + + cleanupTestRepositoryPathFiles(runPathFile) + + expect(ownedRepositories.every((repository) => !existsSync(repository))).toBe(true) + expect(publications.every((publication) => !existsSync(publication))).toBe(true) + expect(existsSync(unrelatedRepo)).toBe(true) + expect(existsSync(unrelatedPublication)).toBe(true) + }) + it('removes every linked run worktree and preserves unrelated siblings', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-teardown-contract-')) roots.push(root) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index af0915a6621..e1d34d72229 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -21,10 +21,9 @@ import { type ElectronApplication, type TestInfo } from '@stablyai/playwright-test' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { TEST_REPO_PATH_FILE } from '../global-setup' import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { retryTransientMainEvaluate } from './electron-main-evaluate-retry' @@ -33,7 +32,11 @@ import { assertElectronResolvedIsolatedHome, createElectronHomeIsolation } from './electron-home-isolation' -import { createSeededTestRepo, isValidGitRepo } from './seeded-test-repo' +import { + createSeededTestRepo, + isValidGitRepo, + provideWorkerTestRepository +} from './seeded-test-repo' type OrcaTestFixtures = { electronApp: ElectronApplication @@ -66,7 +69,7 @@ type OrcaTestFixtures = { } type OrcaWorkerFixtures = { - /** Absolute path to the test git repo created by globalSetup. */ + /** Absolute path to this worker's disposable test git repo. */ testRepoPath: string } @@ -142,19 +145,13 @@ export function forwardElectronProcessLogs(app: ElectronApplication, testInfo: T * userData directory so state cannot leak across specs through persistence. */ export const test = base.extend({ - // Worker-scoped: read the test repo path once + // Auto: restart-only specs also read the published path without requesting an app fixture. testRepoPath: [ // oxlint-disable-next-line no-empty-pattern -- Playwright fixture callbacks require object destructuring here. async ({}, provideFixture) => { - const persistedRepoPath = existsSync(TEST_REPO_PATH_FILE) - ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() - : '' - const repoPath = isValidGitRepo(persistedRepoPath) - ? persistedRepoPath - : createSeededTestRepo() - await provideFixture(repoPath) + await provideWorkerTestRepository(provideFixture) }, - { scope: 'worker' } + { scope: 'worker', auto: true } ], // Why: Windows keeps watched worktrees locked until Electron and its @@ -374,9 +371,7 @@ export const test = base.extend({ }, seededRepoId) .catch(() => false) - // Why: parallel specs mutate real git worktrees in the shared fixture repo. - // A first scan can briefly return no rows while git holds a worktree lock, - // so poll the public fetch path until the seeded primary + secondary load. + // Wait for the public fetch path to discover both seeded worktrees. await playwrightExpect .poll( () => diff --git a/tests/e2e/helpers/seeded-test-repo.ts b/tests/e2e/helpers/seeded-test-repo.ts index 34c4f346714..866fc42dad6 100644 --- a/tests/e2e/helpers/seeded-test-repo.ts +++ b/tests/e2e/helpers/seeded-test-repo.ts @@ -3,12 +3,38 @@ * disposable test repo (plus its secondary worktree) that specs operate on. */ -import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' import { execSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import os from 'node:os' import path from 'node:path' import { TEST_REPO_PATH_FILE } from '../global-setup' +import { cleanupTestRepository } from '../global-teardown' + +export async function provideWorkerTestRepository( + provideFixture: (repository: string) => Promise +): Promise { + const persistedRepoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + : '' + const repoPath = isValidGitRepo(persistedRepoPath) ? persistedRepoPath : createSeededTestRepo() + try { + await provideFixture(repoPath) + } finally { + if (existsSync(repoPath)) { + cleanupTestRepository(repoPath) + } + rmSync(TEST_REPO_PATH_FILE, { force: true }) + } +} export function isValidGitRepo(repoPath: string): boolean { if (!repoPath || !existsSync(repoPath)) { From 7ad76f801b8579c25420e6625507c6fd9105786d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:33:34 -0700 Subject: [PATCH 07/26] test: hold the usage snapshot burst clock fixed (#24551) --- src/main/usage/agent-token-usage-reporter.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/usage/agent-token-usage-reporter.test.ts b/src/main/usage/agent-token-usage-reporter.test.ts index 8ecf487838c..b1572ce6cf5 100644 --- a/src/main/usage/agent-token-usage-reporter.test.ts +++ b/src/main/usage/agent-token-usage-reporter.test.ts @@ -138,6 +138,7 @@ describe('agent token usage', () => { }) it('retries rate-limited snapshots without incrementing their revision', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000) const instance = reporter() for (let count = 1; count <= 31; count++) { await instance.report([{ ...row, input_tokens: count }]) @@ -145,6 +146,7 @@ describe('agent token usage', () => { expect(captures()).toHaveLength(30) resetBurstCapsForSession() await instance.report([{ ...row, input_tokens: 31 }]) + expect(captures()).toHaveLength(31) expect(captures().at(-1)).toMatchObject({ revision: 31, input_tokens: 31 }) }) From da51e5a1481758ddd60fa0cf6df1438cbbcf5115 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:34:15 +0000 Subject: [PATCH 08/26] fix(ssh): name missing build tools when the relay has no node-pty (#22670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an SSH host without a C/C++ compiler, the relay installs without node-pty, and opening a terminal used to say "could not establish why … reconnect to retry", which never helped. The relay now treats a missing node-pty folder ("not found" only) as not installed, runs its existing build-tools check, and names the missing tools with the install command for the host's package manager. It diagnoses the node-pty install the relay's import actually resolved, and keeps any other error as "can't tell". Part of #20386. Removing the need for a compiler on the host is #1693. --- src/relay/node-pty-binding-survey.test.ts | 93 +++++++++++++++++-- src/relay/node-pty-binding-survey.ts | 53 +++++++++-- .../node-pty-unavailable-diagnosis.test.ts | 48 ++++++++++ src/relay/node-pty-unavailable-diagnosis.ts | 23 ++++- src/relay/pty-handler-spawn-admission.test.ts | 90 ++++++++++++++---- src/relay/pty-handler.ts | 10 +- .../terminal-pane/ipc-pty-connect.ts | 1 + .../pty-transport-spawn-errors.test.ts | 19 ++++ 8 files changed, 296 insertions(+), 41 deletions(-) diff --git a/src/relay/node-pty-binding-survey.test.ts b/src/relay/node-pty-binding-survey.test.ts index b51a57fd4a8..8787713c117 100644 --- a/src/relay/node-pty-binding-survey.test.ts +++ b/src/relay/node-pty-binding-survey.test.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -7,6 +15,7 @@ import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-d import { collectNodePtyUnavailableDiagnosis, readNodeGypBuildRecord, + resolveNodePtyInstallDir, surveyNodePtyBinding } from './node-pty-binding-survey' import { formatNodePtyUnavailableMessage } from './node-pty-unavailable-diagnosis' @@ -73,6 +82,26 @@ describe('readNodeGypBuildRecord', () => { }) }) +describe('resolveNodePtyInstallDir', () => { + it('finds the install an ancestor node_modules supplies, as the bare import does', () => { + // realpath: the resolver answers the real path, and macOS tmpdir is a link. + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-node-pty-'))) + roots.push(root) + const installDir = join(root, 'node_modules', 'node-pty') + mkdirSync(installDir, { recursive: true }) + writeFileSync(join(installDir, 'package.json'), '{}\n') + + expect(resolveNodePtyInstallDir(join(root, 'relay', 'abc123'))).toBe(installDir) + }) + + it('answers nothing when no node_modules up the tree holds node-pty', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + + expect(resolveNodePtyInstallDir(root)).toBeNull() + }) +}) + describe('collectNodePtyUnavailableDiagnosis', () => { it("recovers the dynamic loader's own words that node-pty threw away", async () => { // The whole defect in one assertion: what reaches the relay is FLATTENED, which names @@ -107,17 +136,61 @@ describe('collectNodePtyUnavailableDiagnosis', () => { } }, 20_000) - it('reports an unlocatable install as unverifiable, not as a diagnosis', async () => { + it('diagnoses a node-pty directory that does not exist instead of calling it unverifiable (#20386)', async () => { + // What a Linux host without a compiler gets: the deploy reinstalls with node-pty removed. + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + const missingDir = join(root, 'node-pty') const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: null, - error: new Error(FLATTENED) + nodePtyDir: missingDir, + error: new Error(`no node-pty at ${join(missingDir, 'lib', 'index.js')}`) }) - expect(diagnosis.status).toBe('unverifiable') - const text = formatNodePtyUnavailableMessage(diagnosis) - expect(text).toContain('could not establish why') - // It still has to be reportable: the raw error is the only thing an issue can quote. - expect(text).toContain(FLATTENED) - }) + expect(diagnosis.status).toBe('blocked') + expect(['toolchain_missing', 'dependency_missing']).toContain(diagnosis.reason) + expect(diagnosis.survey).toMatchObject({ installed: false, bindingPath: null }) + expect(diagnosis.toolchain === null).toBe(process.platform !== 'linux') + expect(formatNodePtyUnavailableMessage(diagnosis)).toContain( + `node-pty is not installed at ${missingDir}` + ) + }, 20_000) + + it.skipIf(process.platform === 'win32')( + 'treats a node-pty link whose target is gone as not installed', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + const linked = join(root, 'node-pty') + symlinkSync(join(root, 'gone'), linked) + const diagnosis = await collectNodePtyUnavailableDiagnosis({ nodePtyDir: linked }) + expect(diagnosis.survey).toMatchObject({ installed: false, bindingPath: null }) + }, + 20_000 + ) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'keeps a directory it was refused as unverifiable rather than absent', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + const locked = join(root, 'locked') + mkdirSync(join(locked, 'node-pty'), { recursive: true }) + chmodSync(locked, 0o000) + try { + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: join(locked, 'node-pty'), + error: new Error(FLATTENED) + }) + expect(diagnosis.status).toBe('unverifiable') + expect(diagnosis.detail).toContain('EACCES') + const text = formatNodePtyUnavailableMessage(diagnosis) + expect(text).toContain('could not establish why') + // It still has to be reportable: the raw error is the only thing an issue can quote. + expect(text).toContain(FLATTENED) + } finally { + chmodSync(locked, 0o755) + } + } + ) it('probes the host toolchain only when nothing was compiled', async () => { const diagnosis = await collectNodePtyUnavailableDiagnosis({ diff --git a/src/relay/node-pty-binding-survey.ts b/src/relay/node-pty-binding-survey.ts index 805527f1aad..16a051c2302 100644 --- a/src/relay/node-pty-binding-survey.ts +++ b/src/relay/node-pty-binding-survey.ts @@ -19,8 +19,9 @@ * Every step is best-effort and failure-tolerant: whatever cannot be established is * reported as unestablished rather than guessed (docs/reference/ssh-execution-boundary.md). */ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, readFileSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' import { release } from 'node:os' import process from 'node:process' import { runProcess } from '../shared/child-process/run-process' @@ -85,6 +86,7 @@ export function surveyNodePtyBinding( const built = readNodeGypBuildRecord(nodePtyDir) return { moduleDir: nodePtyDir, + installed: true, bindingPath, searched, builtNodeAbi: built.nodeAbi, @@ -181,6 +183,34 @@ async function probeRelayBuildToolchain( } } +/** The install a bare `node-pty` import from `fromDir` loads — Node walks up, so it can be an ancestor's. */ +export function resolveNodePtyInstallDir(fromDir: string): string | null { + try { + return dirname(createRequire(join(fromDir, 'relay.js')).resolve('node-pty/package.json')) + } catch { + return null + } +} + +// Only ENOENT is absence — the relay observing its own host. `stat` judges the directory the +// loader reads, so a dangling link is absent; `existsSync` would also answer false for EACCES. +function readNodePtyDirPresence( + nodePtyDir: string +): 'present' | 'absent' | { unverifiable: string } { + try { + statSync(nodePtyDir) + return 'present' + } catch (error) { + const code = error instanceof Error && 'code' in error ? String(error.code) : null + if (code === 'ENOENT') { + return 'absent' + } + return { + unverifiable: `the relay could not read its node-pty install directory (${code ?? readErrorMessage(error) ?? 'unknown error'})` + } + } +} + function readErrorMessage(error: unknown): string | null { if (error instanceof Error) { return error.message @@ -193,21 +223,32 @@ function readErrorMessage(error: unknown): string | null { * one's answer. Called only on the failure path, so a spawn that works pays nothing. */ export async function collectNodePtyUnavailableDiagnosis(options: { - nodePtyDir: string | null + nodePtyDir: string error?: unknown }): Promise { const abi = detectNativeHostAbi() const host: NodePtyUnavailableHost = { ...abi, nodeVersion: process.version } const requireError = readErrorMessage(options.error) - if (!options.nodePtyDir) { + const presence = readNodePtyDirPresence(options.nodePtyDir) + if (typeof presence === 'object') { return diagnoseNodePtyUnavailable({ host, survey: null, requireError, - unverifiableBecause: 'the relay could not locate its node-pty install directory' + unverifiableBecause: presence.unverifiable }) } - const survey = surveyNodePtyBinding(options.nodePtyDir, host) + const survey: NodePtyBindingSurvey | null = + presence === 'absent' + ? { + moduleDir: options.nodePtyDir, + installed: false, + bindingPath: null, + searched: [], + builtNodeAbi: null, + builtArch: null + } + : surveyNodePtyBinding(options.nodePtyDir, host) const probed = survey?.bindingPath ? await probeNodePtyLoader(options.nodePtyDir) : {} const toolchain = survey && !survey.bindingPath ? await probeRelayBuildToolchain(host.platform) : null diff --git a/src/relay/node-pty-unavailable-diagnosis.test.ts b/src/relay/node-pty-unavailable-diagnosis.test.ts index 7bed6ef256e..ca661e4bd2e 100644 --- a/src/relay/node-pty-unavailable-diagnosis.test.ts +++ b/src/relay/node-pty-unavailable-diagnosis.test.ts @@ -27,6 +27,7 @@ const SEARCHED = ['build/Release', 'build/Debug', 'prebuilds/linux-x64'] const INSTALLED: NodePtyBindingSurvey = { moduleDir: MODULE_DIR, + installed: true, bindingPath: `${MODULE_DIR}/build/Release/pty.node`, searched: SEARCHED, builtNodeAbi: null, @@ -140,6 +141,53 @@ describe('diagnoseNodePtyUnavailable', () => { }) expect(present.reason).toBe('dependency_missing') expect(formatNodePtyUnavailableMessage(present)).not.toContain('apt-get') + expect(formatNodePtyUnavailableMessage(present)).toContain( + 'build tools needed to compile it are present' + ) + }) + + it('never claims the build tools are present when no toolchain probe answered', () => { + // docs/reference/ssh-execution-boundary.md: a probe that timed out established nothing. + const linuxUnchecked = message({ survey: NOTHING_INSTALLED, toolchain: null }) + expect(linuxUnchecked).not.toContain('are present') + expect(linuxUnchecked).toContain('could not be checked') + expect(linuxUnchecked).toContain('Reconnect to reinstall') + + // Off Linux the probe never runs: node-pty ships prebuilds, so tools are beside the point. + const macos = message({ + host: { ...UBUNTU_2004, platform: 'darwin', libc: 'none', glibcVersion: null }, + survey: NOTHING_INSTALLED, + toolchain: null + }) + expect(macos).not.toContain('build tools') + expect(macos).toContain('Reconnect to reinstall') + }) + + it('names an absent node-pty directory as not installed and still offers the build tools (#20386)', () => { + // The no-toolchain deploy removes node-pty outright, so there is no directory to search. + const text = message({ + survey: { ...NOTHING_INSTALLED, installed: false, searched: [] }, + toolchain: toolchain(['python3']) + }) + expect(text).toContain(`node-pty is not installed at ${MODULE_DIR}`) + expect(text).toContain('sudo apt-get install -y build-essential python3') + expect(text).not.toContain('reconnect to retry') + }) + + it('does not blame the build tools for an absent directory on a host that has them', () => { + // The node-pty-less reinstall also leaves no directory when it fails for a non-toolchain + // reason (ENOSPC, registry unreachable). Naming tools the host already has is the + // confidently-wrong diagnosis #20386's fix must not introduce. + const verdict = diagnose({ + survey: { ...NOTHING_INSTALLED, installed: false, searched: [] }, + toolchain: toolchain(['make', 'g++', 'python3']) + }) + expect(verdict.reason).toBe('dependency_missing') + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain(`node-pty is not installed at ${MODULE_DIR}`) + expect(text).toContain('build tools needed to compile it are present') + expect(text).not.toContain('apt-get') + expect(text).not.toContain('not installed.') }) it('reports a binding that killed the probe as a crash rather than a miss', () => { diff --git a/src/relay/node-pty-unavailable-diagnosis.ts b/src/relay/node-pty-unavailable-diagnosis.ts index 590eedcc375..b8d8fdbdda5 100644 --- a/src/relay/node-pty-unavailable-diagnosis.ts +++ b/src/relay/node-pty-unavailable-diagnosis.ts @@ -33,6 +33,8 @@ import type { TerminalUnavailableCause } from '../shared/terminal-unavailable-ca export type NodePtyBindingSurvey = { /** The node-pty install the relay would load from. */ moduleDir: string + /** False when `moduleDir` itself is absent — the no-toolchain deploy skips node-pty entirely. */ + installed: boolean /** The compiled binding the loader would open, or null when no directory holds one. */ bindingPath: string | null /** Directories checked, so "nothing is installed" is a statement with evidence. */ @@ -312,8 +314,7 @@ function remedyFor(diagnosis: NodePtyUnavailableDiagnosis): string { case 'dependency_missing': return ( `node-pty has no compiled binary on this host (${searchedPhrase(survey)}). ` + - `The C/C++ build tools needed to compile it are present, so reconnect to reinstall ` + - `the relay's native modules.` + `${toolchainPresenceSentence(host, toolchain)}Reconnect to reinstall the relay's native modules.` ) case 'abi_mismatch': return ( @@ -355,7 +356,25 @@ function remedyFor(diagnosis: NodePtyUnavailableDiagnosis): string { } } +// Claims "present" only off a probe that answered; the probe runs on Linux only, since +// node-pty ships prebuilds elsewhere and a reinstall there needs no compiler. +function toolchainPresenceSentence( + host: NodePtyUnavailableHost, + toolchain: BuildToolchainStatus | null +): string { + if (toolchain) { + return 'The C/C++ build tools needed to compile it are present. ' + } + if (host.platform === 'linux') { + return 'Whether the C/C++ build tools needed to compile it are installed could not be checked. ' + } + return '' +} + function searchedPhrase(survey: NodePtyBindingSurvey | null): string { + if (survey && !survey.installed) { + return `node-pty is not installed at ${survey.moduleDir}` + } return survey && survey.searched.length > 0 ? `checked ${survey.searched.join(', ')} under ${survey.moduleDir}` : 'nothing was found where node-pty looks' diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index ea5c6ca3486..7e0dc3f70c6 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -1,11 +1,13 @@ import './mock-descendant-sweep' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import * as ptyChildProcessInspection from './pty-child-process-inspection' import * as ptyShellUtils from './pty-shell-utils' import * as processTableSnapshotReader from '../shared/process-table-snapshot-reader' +import * as runProcessModule from '../shared/child-process/run-process' +import * as nodePtyBindingSurvey from './node-pty-binding-survey' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -325,30 +327,78 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(0) }) - it('keeps the load error it was handed instead of replacing it with guesses', async () => { - // #17830: the user got three remedies for four possible faults and could verify none. - // The relay must carry what it was actually told, and must not prescribe a toolchain - // install it never probed for. - const thrown = - 'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64' - mockPtySpawn.mockImplementationOnce(() => { - throw new Error(thrown) - }) + const THROWN_LOAD_ERROR = + 'Failed to load native module: pty.node, checked: build/Release, prebuilds/linux-x64' - const message = await dispatcher.callRequest('pty.spawn', {}).then( - () => '', - (error: Error) => error.message + function failSpawnWithToolchainProbe( + toolchainProbeStdout: string + ): Promise<(Error & { data?: unknown }) | null> { + const realRunProcess = runProcessModule.runProcess + vi.spyOn(runProcessModule, 'runProcess').mockImplementation((spec) => + spec.program === '/bin/sh' + ? Promise.resolve({ + stdout: toolchainProbeStdout, + stderr: '', + code: 0, + signal: null, + timedOut: false + }) + : realRunProcess(spec) ) + mockPtySpawn.mockImplementationOnce(() => { + throw new Error(THROWN_LOAD_ERROR) + }) + return dispatcher.callRequest('pty.spawn', {}).then( + () => null, + (error: Error & { data?: unknown }) => error + ) + } - expect(message).toContain(thrown) - expect(message).not.toContain('install make, a C++ compiler, and python3') - // Nothing here established a cause — the relay's node-pty directory is not on disk in - // this harness — so per docs/reference/ssh-execution-boundary.md it must say so rather - // than pick a diagnosis. Every message still names the host, for the bug report. - expect(message).toContain('could not establish why') + it('diagnoses a relay installed without node-pty instead of asking for a reconnect (#20386)', async () => { + // Relies on no node-pty beside the relay source — what the no-toolchain deploy leaves. Absence + // is observed on the owning host, so it is a diagnosis (docs/reference/ssh-execution-boundary.md). + expect(typeof process.resourcesPath, 'packaged node-pty lookup must be off').not.toBe('string') + expect( + existsSync(join(__dirname, 'node_modules', 'node-pty')), + 'a node-pty beside src/relay would turn this into the installed-but-unbuilt case' + ).toBe(false) + // The checkout's own node_modules holds a node-pty an SSH host's relay dir never has. + vi.spyOn(nodePtyBindingSurvey, 'resolveNodePtyInstallDir').mockReturnValue(null) + + const rejection = await failSpawnWithToolchainProbe('HAVE python3\nPKG apt-get\n') + const message = rejection?.message ?? '' + + // #17830: the load error the relay was handed still travels with the rejection. + // No compiler means no rebuild, so the client must not auto-reconnect on this one. + expect(rejection?.data).toMatchObject({ + reason: 'toolchain_missing', + repairable: false, + rawError: THROWN_LOAD_ERROR + }) + expect(message).not.toContain('could not establish why') + expect(message).toContain('node-pty is not installed at') + expect(message).toContain('sudo apt-get install -y build-essential python3') + // Every message still names the host, for the bug report. expect(message).toMatch(/Host: linux\/\w+, .*Node v[\d.]+ \(ABI \d+\)/) }) + it('diagnoses the node-pty an ancestor node_modules supplied, not the absent one beside the relay', async () => { + const ancestorInstall = join(mkdtempSync(join(tmpdir(), 'orca-node-pty-')), 'node-pty') + mkdirSync(ancestorInstall) + vi.spyOn(nodePtyBindingSurvey, 'resolveNodePtyInstallDir').mockReturnValue(ancestorInstall) + + try { + const message = + (await failSpawnWithToolchainProbe('HAVE make\nHAVE g++\nHAVE python3\nPKG apt-get\n')) + ?.message ?? '' + + expect(message).not.toContain('node-pty is not installed at') + expect(message).toContain(`under ${ancestorInstall}`) + } finally { + rmSync(dirname(ancestorInstall), { recursive: true, force: true }) + } + }) + it('preserves unrelated node-pty spawn failures', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error('File not found: missing-shell.exe') diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cb88537de44..fbad4a212a0 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -128,7 +128,10 @@ import { injectRelayHistoryEnv } from './terminal-history' import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' -import { collectNodePtyUnavailableDiagnosis } from './node-pty-binding-survey' +import { + collectNodePtyUnavailableDiagnosis, + resolveNodePtyInstallDir +} from './node-pty-binding-survey' import { describeRelayRuntime } from './relay-runtime-identity' import { relayConptyDllSpawnOptions } from './relay-windows-conpty' import { @@ -670,9 +673,10 @@ export class PtyHandler { * healthy relay never pays for them. */ private async nodePtyUnavailableError(spawnError?: unknown): Promise { - const nodePtyDir = this.relayNodePtyDir() + // Why: diagnose the install the bare import loaded; the bundle's own dir is only the fallback. + const nodePtyDir = resolveNodePtyInstallDir(__dirname) ?? this.relayNodePtyDir() const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: existsSync(nodePtyDir) ? nodePtyDir : null, + nodePtyDir, error: spawnError ?? this.lastPtyLoadError }) return Object.assign(new Error(formatNodePtyUnavailableMessage(diagnosis)), { diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index ea5a4dda3aa..e8c0c2caf4f 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -181,6 +181,7 @@ function handleConnectError( context: IpcPtyConnectContext ): PtyConnectResult | undefined { const { connectionId } = context.transportOptions + // Unclamped: host diagnoses put the remedy on later lines, and the pane toast renders them all. const message = readIpcErrorDetail(error) ?? (error instanceof Error ? error.message : String(error)) if (connectionId && options.sessionId && isSshSessionGoneError(message)) { diff --git a/src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts index 2ff2c9062a2..6a181e70f7c 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts @@ -260,4 +260,23 @@ describe('createIpcPtyTransport', () => { expect(onError).toHaveBeenCalledWith(createTerminalSessionStateSaveFailureMessage()) }) + + it('keeps every line of a multi-line spawn failure for the pane toast (#20386)', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const diagnosis = + 'Remote terminals are unavailable: make and a C++ compiler are not installed. Install them on the remote host, then reconnect:\n' + + ' sudo apt-get install -y build-essential python3\n' + + 'Host: linux/x64, glibc 2.36, Node v22.12.0 (ABI 127), prebuild slot linux-x64-glibc.' + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( + new Error(`Error invoking remote method 'pty:spawn': Error: ${diagnosis}`) + ) + + const onError = vi.fn() + await createIpcPtyTransport({ connectionId: 'ssh-1' }).connect({ + url: '', + callbacks: { onError } + }) + + expect(onError).toHaveBeenCalledWith(diagnosis) + }) }) From 34ae0933e4275675b28041d1ee7d84667d84de26 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:37:05 -0700 Subject: [PATCH 09/26] fix(worktrees): keep creation fast in large repositories (#24346) * fix(worktrees): remove repeated scans and keep prepared checkouts fresh * fix(worktrees): reclaim unlocked fallback preparations safely * refactor(worktrees): simplify creation ownership and idle maintenance * fix(git): keep ref maintenance armed after an index-only pass An idle attempt that found the pack index due but refs still cooling down returned without rescheduling, so loose refs from the arming fetch waited for the next write instead of the ref cooldown. --- docs/reference/git-compatibility.md | 14 + .../__mocks__/worktree-create-preparation.ts | 114 ++++++ .../git/local-repo-ref-maintenance.test.ts | 17 +- src/main/git/local-repo-ref-maintenance.ts | 34 +- .../git/repo-pack-index-maintenance.test.ts | 258 +++++++++++++ src/main/git/repo-pack-index-maintenance.ts | 122 ++++++ src/main/git/repo-pack-index-state.test.ts | 63 ++++ src/main/git/repo-pack-index-state.ts | 94 +++++ .../git/repo-ref-maintenance-real-git.test.ts | 162 +++++++- .../worktree-create-admission-tier.test.ts | 22 +- ...ktree-create-git-executor-real-git.test.ts | 5 +- ...rktree-create-preparation-real-git.test.ts | 356 ++++++++++++++++-- ...rktree-create-preparation-real-wsl.test.ts | 16 +- src/main/git/worktree-create-preparation.ts | 194 ++++------ ...rktree-mutation-route-invalidation.test.ts | 45 ++- src/main/git/worktree-preparation-add.test.ts | 332 ++++++++++++++++ src/main/git/worktree-preparation-add.ts | 119 ++++++ .../git/worktree-preparation-base-oid.test.ts | 35 +- ...e-preparation-cancel-latency.bench.test.ts | 22 +- src/main/git/worktree-preparation-discard.ts | 89 +++++ ...paration-fallback-cleanup-real-git.test.ts | 207 ++++++++++ ...worktree-preparation-lock-real-git.test.ts | 336 +++++++++++++++++ .../git/worktree-preparation-lock.test.ts | 193 ++++++++++ src/main/git/worktree-preparation-lock.ts | 130 +++++++ ...e-preparation-tip-refresh-real-git.test.ts | 156 ++++++++ .../worktree-preparation-tip-refresh.test.ts | 120 ++++++ .../git/worktree-preparation-tip-refresh.ts | 60 +++ .../created-worktree-reconciliation.test.ts | 50 ++- .../ipc/created-worktree-reconciliation.ts | 74 ++-- src/main/ipc/worktree-remote.ts | 66 ++-- .../ipc/worktrees-local-create-flow.test.ts | 30 +- .../ipc/worktrees-wsl-runtime-routing.test.ts | 7 +- .../register-worktree-prefetch-handler.ts | 3 +- .../orca-runtime-activate-managed-worktree.ts | 46 ++- .../orca-runtime-create-base-prefetch.test.ts | 3 +- .../orca-runtime-create-managed-worktree.ts | 4 +- ...ca-runtime-create-terminal-dependencies.ts | 9 +- .../orca-runtime-create-terminal-desktop.ts | 6 +- .../runtime/orca-runtime-create-terminal.ts | 28 +- ...get-worktree-terminal-provisioning-host.ts | 19 +- ...ser-network-execution-host-for-worktree.ts | 14 +- .../orca-runtime-split-pty-backed-terminal.ts | 9 +- .../runtime/orca-runtime-split-terminal.ts | 7 +- ...orktree-removal-and-reconciliation.spec.ts | 12 +- ...worktree-setup-and-startup-part-02.spec.ts | 8 +- ...e-created-worktree-terminal-target.test.ts | 238 ++++++++++++ ...untime-created-worktree-terminal-target.ts | 106 ++++++ .../runtime/runtime-terminal-pane-identity.ts | 21 +- .../worktree-create-base-prefetch.test.ts | 56 ++- src/main/worktree-create-base-prefetch.ts | 30 +- ...ee-create-preparation-cancellation.test.ts | 2 +- src/main/worktree-create-preparation-pool.ts | 128 ++++--- ...rktree-create-preparation-stale-cleanup.ts | 16 +- src/main/worktree-create-preparation.test.ts | 351 ++--------------- src/main/worktree-create-preparation.ts | 24 +- ...worktree-preparation-discard-retry.test.ts | 241 ++++++++++++ .../worktree-preparation-discard-retry.ts | 33 +- ...worktree-preparation-fetch-barrier.test.ts | 128 +++++++ .../worktree-preparation-refresh-queue.ts | 42 +++ .../worktree-preparation-tip-refresh.test.ts | 225 +++++++++++ src/shared/git-binary-compatibility.test.ts | 60 ++- src/shared/git-capability-cache.ts | 1 + .../git-worktree-command-capabilities.ts | 6 + src/shared/repo-maintenance-schedule.ts | 69 ++++ .../repo-pack-index-maintenance-policy.ts | 11 + src/shared/repo-pack-index-scheduling.test.ts | 155 ++++++++ src/shared/repo-ref-maintenance-policy.ts | 9 + src/shared/repo-ref-maintenance.test.ts | 84 ++++- src/shared/repo-ref-maintenance.ts | 84 +++-- 69 files changed, 5024 insertions(+), 806 deletions(-) create mode 100644 src/main/__mocks__/worktree-create-preparation.ts create mode 100644 src/main/git/repo-pack-index-maintenance.test.ts create mode 100644 src/main/git/repo-pack-index-maintenance.ts create mode 100644 src/main/git/repo-pack-index-state.test.ts create mode 100644 src/main/git/repo-pack-index-state.ts create mode 100644 src/main/git/worktree-preparation-add.test.ts create mode 100644 src/main/git/worktree-preparation-add.ts create mode 100644 src/main/git/worktree-preparation-discard.ts create mode 100644 src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts create mode 100644 src/main/git/worktree-preparation-lock-real-git.test.ts create mode 100644 src/main/git/worktree-preparation-lock.test.ts create mode 100644 src/main/git/worktree-preparation-lock.ts create mode 100644 src/main/git/worktree-preparation-tip-refresh-real-git.test.ts create mode 100644 src/main/git/worktree-preparation-tip-refresh.test.ts create mode 100644 src/main/git/worktree-preparation-tip-refresh.ts create mode 100644 src/main/runtime/runtime-created-worktree-terminal-target.test.ts create mode 100644 src/main/runtime/runtime-created-worktree-terminal-target.ts create mode 100644 src/main/worktree-preparation-discard-retry.test.ts create mode 100644 src/main/worktree-preparation-fetch-barrier.test.ts create mode 100644 src/main/worktree-preparation-refresh-queue.ts create mode 100644 src/main/worktree-preparation-tip-refresh.test.ts create mode 100644 src/shared/repo-maintenance-schedule.ts create mode 100644 src/shared/repo-pack-index-maintenance-policy.ts create mode 100644 src/shared/repo-pack-index-scheduling.test.ts diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index d537c4d94de..bbfb4a84876 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -37,11 +37,21 @@ authority. | --------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fetch-no-write-fetch-head` | Fetch a private rebase ref without changing worktree-local `FETCH_HEAD` | Serialize all Orca fetch/pull operations per worktree Git directory before Git 2.29 | | `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 | +| `worktree-add-lock-reason` | Create a prepared checkout with its ownership marker already present | Before Git 2.33, add without checkout, then exclusively create the same reason marker before materializing files | | `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | | `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | | `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | +Prepared creation registers and locks without checking out files while its shared +exact-base fetch runs. A cancellable in-process barrier waits for fetch settlement, +including offline failure, then resolves the current commit OID on the owning host +and materializes files once. Existing preparations queue a tip refresh on that same +barrier before a create can claim them. Finalization still resolves the latest base +and runs the post-checkout hook only when attaching the requested branch. This uses +baseline-compatible `rev-parse` and `reset --hard`; the barrier never enters Git +transport options or the remote wire. + ### Placeholders That Fail Open `GitCapabilityCache` records commands Git _rejects_. A `git log --format` @@ -75,6 +85,10 @@ container start, so their wall clock is runner contention, not Git. Build the running alongside them is charged to whichever boundary case is in flight and surfaces as a Vitest timeout rather than as a slow setup step. +The idle maintenance contract also verifies `multi-pack-index write` and packed +object reads. The command arrived in Git 2.20 and needs no newer-Git fallback; +Orca uses only index metadata writes, respecting `core.multiPackIndex=false`. + Keep the unit tests alongside that matrix. They cover concurrent probes, native/WSL/SSH/relay isolation, and error-stream shapes that a single real binary invocation cannot exercise deterministically. diff --git a/src/main/__mocks__/worktree-create-preparation.ts b/src/main/__mocks__/worktree-create-preparation.ts new file mode 100644 index 00000000000..74e48a3f8c9 --- /dev/null +++ b/src/main/__mocks__/worktree-create-preparation.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import type * as WorktreeLogic from '../ipc/worktree-logic' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' + +const mocks: { + mkdir: Mock + listWorktreeGraph: Mock + prepareCheckout: Mock + refreshTip: Mock + finalize: Mock + discard: Mock + unlock: Mock + getWorktreeOptions: Mock + computeWorkspaceRoot: Mock + computeWorkspaceRootAsync: Mock + resolveBaseRef: Mock + measureDivergence: Mock +} = vi.hoisted(() => ({ + mkdir: vi.fn(), + listWorktreeGraph: vi.fn(), + prepareCheckout: vi.fn(), + refreshTip: vi.fn(), + finalize: vi.fn(), + discard: vi.fn(), + unlock: vi.fn(), + getWorktreeOptions: vi.fn(), + computeWorkspaceRoot: vi.fn(), + computeWorkspaceRootAsync: vi.fn(), + resolveBaseRef: vi.fn(), + measureDivergence: vi.fn() +})) + +export { mocks } + +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) +vi.mock('../git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) +vi.mock('../git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepareCheckout, + finalizePreparedWorktree: mocks.finalize, + discardPreparedWorktree: mocks.discard, + unlockPreparedWorktree: mocks.unlock +})) +vi.mock('../git/worktree-preparation-tip-refresh', () => ({ + refreshPreparedWorktreeTip: mocks.refreshTip +})) +vi.mock('../git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) +vi.mock('../git/worktree-base-divergence', () => ({ + measureRetargetDivergence: mocks.measureDivergence +})) +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, + getWorktreeMirrorDistro: () => undefined +})) +vi.mock('../ipc/worktree-logic', async (importOriginal) => ({ + isOrphanedWorktreeError: (await importOriginal()).isOrphanedWorktreeError, + computeWorkspaceRoot: mocks.computeWorkspaceRoot, + computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, + getWorktreePathSettings: () => ({ + workspaceDir: process.platform === 'win32' ? 'C:\\workspace' : '/workspace', + nestWorkspaces: false + }) +})) + +import { _resetWorktreeCreatePreparationsForTests } from '../worktree-create-preparation' + +// Evictions and retries are fire-and-forget, so let them settle before asserting. +export function flushBackgroundWork(ms = 0): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const EXISTING_REFS = new Set([ + 'refs/heads/main', + 'refs/remotes/origin/main', + 'refs/remotes/origin/release' +]) +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked flow reads only this repository identity. +export const repo = { id: 'repo-1', path: '/repo' } as Repo +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked persistence boundary reads only getSettings. +export const store = { getSettings: () => ({}) } as unknown as Store + +beforeEach(() => { + mocks.mkdir.mockReset().mockResolvedValue(undefined) + mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) + mocks.prepareCheckout.mockReset().mockResolvedValue(undefined) + mocks.refreshTip.mockReset().mockResolvedValue(undefined) + mocks.finalize.mockReset().mockResolvedValue({}) + mocks.discard.mockReset().mockResolvedValue(undefined) + mocks.unlock.mockReset().mockResolvedValue(undefined) + mocks.getWorktreeOptions.mockReset().mockReturnValue({}) + mocks.measureDivergence.mockReset().mockResolvedValue('within') + mocks.resolveBaseRef + .mockReset() + .mockImplementation((_repoPath: string, baseRef: string) => + resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) + ) + mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { + throw new Error('synchronous workspace-root lookup must not run on the main thread') + }) + mocks.computeWorkspaceRootAsync + .mockReset() + .mockImplementation(async (repoPath: string) => + process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(repoPath) + ? 'C:\\workspace' + : '/workspace' + ) +}) + +afterEach(async () => { + await _resetWorktreeCreatePreparationsForTests() +}) diff --git a/src/main/git/local-repo-ref-maintenance.test.ts b/src/main/git/local-repo-ref-maintenance.test.ts index f6a411733c3..7d084a4bd5a 100644 --- a/src/main/git/local-repo-ref-maintenance.test.ts +++ b/src/main/git/local-repo-ref-maintenance.test.ts @@ -77,7 +77,12 @@ describe('local repo ref maintenance target', () => { it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => { for (const stdout of [ 'maintenance.auto false\n', + 'maintenance.auto NO\n', + 'maintenance.auto off\n', + 'maintenance.auto 0\n', + 'maintenance.auto \n', 'gc.auto 0\n', + 'gc.auto 0k\n', 'gc.auto 6700\nmaintenance.auto false\n' ]) { gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' }) @@ -91,10 +96,20 @@ describe('local repo ref maintenance target', () => { await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) // `git config --get-regexp` exits non-zero when nothing matches. - gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1')) + gitExecFileAsyncMock.mockRejectedValue(Object.assign(new Error('key unset'), { code: 1 })) await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) }) + it('fails closed when the auto-maintenance config cannot be read', async () => { + for (const error of [ + new Error('spawn failed'), + Object.assign(new Error('bad config'), { code: 128 }) + ]) { + gitExecFileAsyncMock.mockRejectedValue(error) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true) + } + }) + it('walks the POSIX refs directory for a native repo', async () => { readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') diff --git a/src/main/git/local-repo-ref-maintenance.ts b/src/main/git/local-repo-ref-maintenance.ts index e84f7d1ae30..6c0836b58b1 100644 --- a/src/main/git/local-repo-ref-maintenance.ts +++ b/src/main/git/local-repo-ref-maintenance.ts @@ -12,6 +12,11 @@ import { import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { withSpan } from '../observability/tracer' import { PackRefsLockOwnership } from './pack-refs-lock-ownership' +import { + clearRepoPackIndexMaintenanceCache, + isUnsetGitConfigError, + maintainRepoPackIndex +} from './repo-pack-index-maintenance' import { gitExecFileAsync } from './runner' import { readRepoCommonDirFromGit } from './worktree-list-reader' @@ -96,6 +101,7 @@ export function disposeLocalRepoRefMaintenance(): Promise { shared?.dispose() shared = null repoBusyProbes.clear() + clearRepoPackIndexMaintenanceCache() return settling } @@ -149,6 +155,7 @@ export function _resetLocalRepoRefMaintenanceForTests( shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null activityProbe = null repoBusyProbes.clear() + clearRepoPackIndexMaintenanceCache() } /** @@ -174,8 +181,11 @@ function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | unde export function isGitAutoMaintenanceDisabled(configOutput: string): boolean { return configOutput .split('\n') - .map((line) => line.trim()) - .some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0') + .some( + (line) => + /^\s*maintenance\.auto\s+(?:false|no|off|0)?\s*$/i.test(line) || + /^\s*gc\.auto\s+[+-]?0+(?:[kmg])?\s*$/i.test(line) + ) } /** @@ -237,11 +247,25 @@ export function createLocalRepoRefMaintenanceTarget( { cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal } ) return isGitAutoMaintenanceDisabled(stdout) - } catch { - // Neither key set is the common case and exits non-zero; that is consent. - return false + } catch (error) { + // An unset key is consent; unreadable or invalid config must fail closed. + return !isUnsetGitConfigError(error) } }, + async maintainPackIndex(signal, span, canWrite) { + const resolved = await resolveCommonDir(signal) + if (resolved) { + return maintainRepoPackIndex({ + repoPath: args.repoPath, + commonDir: gitCommonDirForMainProcess(resolved, args.wslDistro), + ...gitOptions, + signal, + span, + canWrite + }) + } + return 'failed' + }, async packRefs(lock: PackedRefsLockReporter) { const resolved = await resolveCommonDir() const owner = resolved diff --git a/src/main/git/repo-pack-index-maintenance.test.ts b/src/main/git/repo-pack-index-maintenance.test.ts new file mode 100644 index 00000000000..1fee9644f03 --- /dev/null +++ b/src/main/git/repo-pack-index-maintenance.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, opendirMock, statMock, openMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + opendirMock: vi.fn(), + statMock: vi.fn(), + openMock: vi.fn() +})) + +vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('node:fs/promises', () => ({ opendir: opendirMock, stat: statMock, open: openMock })) + +import { + maintainRepoPackIndex, + clearRepoPackIndexMaintenanceCache, + PACK_INDEX_FORCE_REFRESH_MS, + PACK_INDEX_THRESHOLD, + PACK_INDEX_TIMEOUT_MS +} from './repo-pack-index-maintenance' +import { PACK_INDEX_PROBE_ENTRY_LIMIT } from './repo-pack-index-state' + +function directory(packs: number) { + return { + async *[Symbol.asyncIterator]() { + for (let index = 0; index < packs; index += 1) { + yield { name: `pack-${index}.idx`, isFile: () => true, isSymbolicLink: () => false } + yield { name: `pack-${index}.pack`, isFile: () => true, isSymbolicLink: () => false } + } + } + } +} + +function args(wslDistro?: string) { + const attributes: Record = {} + return { + repoPath: wslDistro ? '//wsl$/Ubuntu/repo' : '/repo', + commonDir: wslDistro ? String.raw`\\wsl.localhost\Ubuntu\repo\.git` : '/repo/.git', + ...(wslDistro ? { wslDistro } : {}), + signal: new AbortController().signal, + canWrite: () => true, + span: { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value + } + }, + attributes + } +} + +beforeEach(() => { + vi.resetAllMocks() + clearRepoPackIndexMaintenanceCache() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'config') { + throw Object.assign(new Error('unset'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }) + opendirMock.mockResolvedValue(directory(PACK_INDEX_THRESHOLD)) + statMock.mockResolvedValue({ dev: 1n, ino: 2n, mtimeNs: 3n, ctimeNs: 4n }) + openMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) +}) + +describe('idle pack index maintenance', () => { + it('leaves a healthy repository alone', async () => { + const options = args() + opendirMock.mockResolvedValue(directory(PACK_INDEX_THRESHOLD - 1)) + await maintainRepoPackIndex(options) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(options.attributes['git.pack_index_outcome']).toBe('below_threshold') + }) + + it('writes only the lookup index with background admission and a deadline', async () => { + const options = args() + await maintainRepoPackIndex(options) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(['multi-pack-index', 'write'], { + cwd: '/repo', + admissionTier: 'background', + timeout: PACK_INDEX_TIMEOUT_MS + }) + expect(options.attributes['git.pack_index_outcome']).toBe('written') + }) + + it('rechecks idle admission after probing and never aborts an admitted writer', async () => { + const options = args() + await maintainRepoPackIndex({ ...options, canWrite: () => false }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(opendirMock).not.toHaveBeenCalled() + expect(options.attributes['git.pack_index_outcome']).toBe('deferred') + let idle = true + opendirMock.mockResolvedValueOnce({ + async *[Symbol.asyncIterator]() { + yield* directory(PACK_INDEX_THRESHOLD) + idle = false + } + }) + await expect(maintainRepoPackIndex({ ...args(), canWrite: () => idle })).resolves.toBe( + 'deferred' + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + await maintainRepoPackIndex(args()) + expect(gitExecFileAsyncMock.mock.lastCall?.[1]).not.toHaveProperty('signal') + }) + + it('caps a directory stream and skips writes when bitmap absence cannot be proved', async () => { + let produced = 0 + let closed = false + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + try { + while (produced < 100_000) { + produced += 1 + yield { name: `pack-${produced}.pack`, isFile: () => true, isSymbolicLink: () => false } + } + } finally { + closed = true + } + } + }) + const options = args() + await maintainRepoPackIndex(options) + expect(produced).toBe(PACK_INDEX_PROBE_ENTRY_LIMIT) + expect(closed).toBe(true) + expect(options.attributes['git.pack_index_outcome']).toBe('protected') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('inspects the measured 11,424-pack repository with four directory entries per pack', async () => { + let produced = 0 + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (let index = 0; index < 11_424; index += 1) { + for (const suffix of ['pack', 'idx', 'rev', 'keep']) { + produced += 1 + yield { + name: `pack-${index}.${suffix}`, + isFile: () => true, + isSymbolicLink: () => false + } + } + } + } + }) + await expect(maintainRepoPackIndex(args())).resolves.toBe('written') + expect(produced).toBe(11_424 * 4) + expect(opendirMock).toHaveBeenCalledOnce() + }) + + it('honours an explicit multi-pack-index opt-out before walking objects', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'false\n', stderr: '' }) + await maintainRepoPackIndex(args()) + expect(opendirMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('fails closed on config errors other than an unset key', async () => { + gitExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('invalid config'), { code: 128 }) + ) + const options = args() + await maintainRepoPackIndex(options) + expect(opendirMock).not.toHaveBeenCalled() + expect(options.attributes['git.pack_index_outcome']).toBe('failed') + }) + + it('does not infer consent from an unreadable boolean or a missing Git binary', async () => { + for (const config of [ + () => Promise.resolve({ stdout: 'unexpected', stderr: '' }), + () => Promise.reject(Object.assign(new Error('missing Git'), { code: 'ENOENT' })) + ]) { + gitExecFileAsyncMock.mockImplementationOnce(config) + const options = args() + await maintainRepoPackIndex(options) + expect(options.attributes['git.pack_index_outcome']).toBe('failed') + } + expect(opendirMock).not.toHaveBeenCalled() + }) + + it('records index failures without preventing later ref maintenance', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce(Object.assign(new Error('unset'), { code: 1 })) + .mockRejectedValueOnce(new Error('index locked')) + const options = args() + await expect(maintainRepoPackIndex(options)).resolves.toBe('failed') + expect(options.attributes['git.pack_index_outcome']).toBe('failed') + }) + + it('skips repositories without pack files and cancelled attempts', async () => { + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + const options = args() + await maintainRepoPackIndex(options) + expect(options.attributes['git.pack_index_outcome']).toBe('below_threshold') + const abort = new AbortController() + abort.abort() + gitExecFileAsyncMock.mockClear() + await maintainRepoPackIndex({ ...args(), signal: abort.signal }) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('walks the WSL share and runs Git on that execution host', async () => { + const options = args('Ubuntu') + await maintainRepoPackIndex(options) + expect(opendirMock).toHaveBeenCalledWith( + String.raw`\\wsl.localhost\Ubuntu\repo\.git\objects\pack` + ) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['multi-pack-index', 'write'], + expect.objectContaining({ cwd: '//wsl$/Ubuntu/repo', wslDistro: 'Ubuntu' }) + ) + }) + + it('skips unchanged directory stamps but refreshes changed packs and periodically rechecks', async () => { + const options = args() + await maintainRepoPackIndex(options) + await expect(maintainRepoPackIndex(options)).resolves.toBe('unchanged') + expect(opendirMock).toHaveBeenCalledOnce() + statMock.mockResolvedValue({ dev: 1n, ino: 2n, mtimeNs: 5n, ctimeNs: 6n }) + await expect(maintainRepoPackIndex(options)).resolves.toBe('written') + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now + PACK_INDEX_FORCE_REFRESH_MS + 1) + await expect(maintainRepoPackIndex(options)).resolves.toBe('written') + vi.restoreAllMocks() + }) + + it('isolates directory stamps by the execution host', async () => { + const options = args() + await maintainRepoPackIndex(options) + await expect(maintainRepoPackIndex({ ...options, wslDistro: 'Ubuntu' })).resolves.toBe( + 'written' + ) + }) + + it('protects metadata discovered after the pack threshold during the final probe', async () => { + opendirMock.mockResolvedValueOnce({ + async *[Symbol.asyncIterator]() { + yield* directory(PACK_INDEX_THRESHOLD) + yield { name: 'multi-pack-index-old.bitmap', isFile: () => true } + } + }) + const options = args() + await expect(maintainRepoPackIndex(options)).resolves.toBe('protected') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not start the writer when the final probe is cancelled', async () => { + const controller = new AbortController() + opendirMock.mockResolvedValueOnce({ + async *[Symbol.asyncIterator]() { + yield* directory(PACK_INDEX_THRESHOLD) + controller.abort() + } + }) + const options = { ...args(), signal: controller.signal } + await expect(maintainRepoPackIndex(options)).resolves.toBe('deferred') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(options.attributes['git.pack_index_pack_count_floor']).toBe(PACK_INDEX_THRESHOLD) + }) +}) diff --git a/src/main/git/repo-pack-index-maintenance.ts b/src/main/git/repo-pack-index-maintenance.ts new file mode 100644 index 00000000000..2177f4d2166 --- /dev/null +++ b/src/main/git/repo-pack-index-maintenance.ts @@ -0,0 +1,122 @@ +import { posix, win32 } from 'node:path' +import { BoundedMap } from '../../shared/bounded-map' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import type { PackIndexMaintenanceOutcome } from '../../shared/repo-pack-index-maintenance-policy' +import type { RefMaintenanceSpan } from '../../shared/repo-ref-maintenance-policy' +import { probeRepoPackIndexDirectory, readRepoPackDirectoryStamp } from './repo-pack-index-state' +import { gitExecFileAsync } from './runner' + +// Git's default auto-GC pack limit is 50; defer indexing until fragmentation is clear. +export const PACK_INDEX_THRESHOLD = 64 +export const PACK_INDEX_TIMEOUT_MS = 60_000 +export const PACK_INDEX_FORCE_REFRESH_MS = 6 * 60 * 60_000 + +const indexedDirectories = new BoundedMap({ + maxEntries: 64 +}) + +export function clearRepoPackIndexMaintenanceCache(): void { + indexedDirectories.clear() +} + +export function isUnsetGitConfigError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 1 +} + +type PackIndexMaintenanceArgs = { + repoPath: string + /** Common directory in the spelling the main process can open. */ + commonDir: string + wslDistro?: string + signal: AbortSignal + span: RefMaintenanceSpan + canWrite: () => boolean +} + +export async function maintainRepoPackIndex( + args: PackIndexMaintenanceArgs +): Promise { + const { signal, span } = args + const outcome = (value: PackIndexMaintenanceOutcome): PackIndexMaintenanceOutcome => { + span.setAttribute('git.pack_index_outcome', value) + return value + } + if (signal.aborted) { + return outcome('deferred') + } + const gitOptions = { + cwd: args.repoPath, + ...(args.wslDistro ? { wslDistro: args.wslDistro } : {}), + admissionTier: 'background' as const, + signal + } + try { + try { + const { stdout } = await gitExecFileAsync( + ['config', '--bool', '--get', 'core.multiPackIndex'], + gitOptions + ) + const configured = stdout.trim() + if (configured === 'false') { + return outcome('opted_out') + } + if (configured !== 'true') { + throw new Error('Unrecognized core.multiPackIndex config') + } + } catch (error) { + if (!isUnsetGitConfigError(error)) { + throw error + } + } + if (signal.aborted) { + return outcome('deferred') + } + const paths = isWindowsAbsolutePathLike(args.commonDir) ? win32 : posix + const directory = paths.join(args.commonDir, 'objects', 'pack') + const stamp = await readRepoPackDirectoryStamp(directory) + if (!stamp) { + return outcome('below_threshold') + } + const key = `${args.wslDistro ? `wsl:${args.wslDistro}` : 'local'}::${args.commonDir}` + const previous = indexedDirectories.get(key) + if ( + previous?.stamp === stamp && + Date.now() - previous.writtenAt < PACK_INDEX_FORCE_REFRESH_MS + ) { + return outcome('unchanged') + } + if (signal.aborted || !args.canWrite()) { + return outcome('deferred') + } + const probe = await probeRepoPackIndexDirectory(directory, PACK_INDEX_THRESHOLD, signal) + span.setAttribute('git.pack_index_pack_count_floor', probe.packCountFloor) + if (signal.aborted || !args.canWrite()) { + return outcome('deferred') + } + if (probe.protected) { + return outcome('protected') + } + if (probe.packCountFloor < PACK_INDEX_THRESHOLD) { + return outcome('below_threshold') + } + const startedAt = Date.now() + // Git 2.20+: writes lookup metadata atomically without rewriting or deleting packs. + // Let the writer finish: force-killing it on Windows can strand its index lock. + await gitExecFileAsync(['multi-pack-index', 'write'], { + cwd: args.repoPath, + ...(args.wslDistro ? { wslDistro: args.wslDistro } : {}), + admissionTier: 'background', + timeout: PACK_INDEX_TIMEOUT_MS + }) + const writtenStamp = await readRepoPackDirectoryStamp(directory).catch(() => undefined) + // A racing new pack remains readable; the forced refresh bounds a missed directory change. + if (writtenStamp) { + indexedDirectories.set(key, { stamp: writtenStamp, writtenAt: Date.now() }) + } + span.setAttribute('git.pack_index_write_ms', Date.now() - startedAt) + return outcome('written') + } catch (error) { + span.setAttribute('git.pack_index_error', String(error)) + return outcome('failed') + } +} diff --git a/src/main/git/repo-pack-index-state.test.ts b/src/main/git/repo-pack-index-state.test.ts new file mode 100644 index 00000000000..86689877351 --- /dev/null +++ b/src/main/git/repo-pack-index-state.test.ts @@ -0,0 +1,63 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { probeRepoPackIndexDirectory } from './repo-pack-index-state' + +const roots: string[] = [] +async function directory(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-pack-index-state-')) + roots.push(root) + return root +} + +function indexHeader(version = 1, hash = 1): Buffer { + const value = Buffer.alloc(1200) + value.write('MIDX') + value[4] = version + value[5] = hash + value[6] = 4 + return value +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('pack-index metadata protection', () => { + it('protects every retained bitmap regardless of its checksum', async () => { + const packs = await directory() + await writeFile(join(packs, 'multi-pack-index'), indexHeader()) + await writeFile(join(packs, 'multi-pack-index-old.bitmap'), 'retained bitmap') + await expect( + probeRepoPackIndexDirectory(packs, 64, new AbortController().signal) + ).resolves.toMatchObject({ protected: true }) + }) + + it('protects an incremental chain without reading its layers', async () => { + const packs = await directory() + await mkdir(join(packs, 'multi-pack-index.d')) + await expect( + probeRepoPackIndexDirectory(packs, 64, new AbortController().signal) + ).resolves.toMatchObject({ protected: true }) + }) + + it.each([indexHeader(2), indexHeader(1, 3), Buffer.from('MIDX'), Buffer.alloc(1200)])( + 'fails closed on an unknown or truncated MIDX', + async (header) => { + const packs = await directory() + await writeFile(join(packs, 'multi-pack-index'), header) + await expect( + probeRepoPackIndexDirectory(packs, 64, new AbortController().signal) + ).resolves.toMatchObject({ protected: true }) + } + ) + + it.each([1, 2])('accepts the standalone v1 SHA hash format %s', async (hash) => { + const packs = await directory() + await writeFile(join(packs, 'multi-pack-index'), indexHeader(1, hash)) + await expect( + probeRepoPackIndexDirectory(packs, 64, new AbortController().signal) + ).resolves.toEqual({ protected: false, packCountFloor: 0 }) + }) +}) diff --git a/src/main/git/repo-pack-index-state.ts b/src/main/git/repo-pack-index-state.ts new file mode 100644 index 00000000000..92ba425d8fd --- /dev/null +++ b/src/main/git/repo-pack-index-state.ts @@ -0,0 +1,94 @@ +import { open, opendir, stat } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +export const PACK_INDEX_PROBE_ENTRY_LIMIT = 65_536 +export const PACK_INDEX_PROBE_TIMEOUT_MS = 1_000 + +export function isMissingPackIndexPath(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} + +export async function readRepoPackDirectoryStamp(directory: string): Promise { + try { + const value = await stat(directory, { bigint: true }) + return `${value.dev}:${value.ino}:${value.mtimeNs}:${value.ctimeNs}` + } catch (error) { + if (isMissingPackIndexPath(error)) { + return undefined + } + throw error + } +} + +async function hasUnsupportedPackIndex(directory: string): Promise { + const paths = isWindowsAbsolutePathLike(directory) ? win32 : posix + const handle = await open(paths.join(directory, 'multi-pack-index'), 'r').catch( + (error: unknown) => { + if (isMissingPackIndexPath(error)) { + return undefined + } + throw error + } + ) + if (!handle) { + return false + } + try { + const header = Buffer.alloc(12) + const { bytesRead } = await handle.read(header, 0, header.length, 0) + const hashBytes = header[5] === 1 ? 20 : header[5] === 2 ? 32 : 0 + const chunks = header[6] ?? 0 + const size = (await handle.stat()).size + if ( + bytesRead !== header.length || + header.subarray(0, 4).toString() !== 'MIDX' || + header[4] !== 1 || + !hashBytes || + chunks < 4 || + header[7] !== 0 || + size < 12 + (chunks + 1) * 12 + 1024 + hashBytes + ) { + return true + } + const trailer = Buffer.alloc(hashBytes) + return (await handle.read(trailer, 0, hashBytes, size - hashBytes)).bytesRead !== hashBytes + } finally { + await handle.close() + } +} + +/** A capped name walk protects every retained bitmap, including an older index's bitmap. */ +export async function probeRepoPackIndexDirectory( + directory: string, + packThreshold: number, + signal: AbortSignal +): Promise<{ packCountFloor: number; protected: boolean }> { + const entries = await opendir(directory) + const startedAt = Date.now() + let visited = 0 + let packCountFloor = 0 + for await (const entry of entries) { + if (signal.aborted) { + return { packCountFloor, protected: true } + } + if ( + entry.name === 'multi-pack-index.d' || + (entry.name.startsWith('multi-pack-index') && entry.name.endsWith('.bitmap')) || + (entry.name === 'multi-pack-index' && !entry.isFile()) + ) { + return { packCountFloor, protected: true } + } + if (entry.name.endsWith('.pack') && (entry.isFile() || entry.isSymbolicLink())) { + packCountFloor = Math.min(packThreshold, packCountFloor + 1) + } + visited += 1 + if ( + visited >= PACK_INDEX_PROBE_ENTRY_LIMIT || + Date.now() - startedAt >= PACK_INDEX_PROBE_TIMEOUT_MS + ) { + return { packCountFloor, protected: true } + } + } + return { packCountFloor, protected: await hasUnsupportedPackIndex(directory) } +} diff --git a/src/main/git/repo-ref-maintenance-real-git.test.ts b/src/main/git/repo-ref-maintenance-real-git.test.ts index 30c67b0365a..7c3041e6dfe 100644 --- a/src/main/git/repo-ref-maintenance-real-git.test.ts +++ b/src/main/git/repo-ref-maintenance-real-git.test.ts @@ -1,10 +1,11 @@ import { execFileSync } from 'node:child_process' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { countLooseRefs } from '../../shared/loose-ref-count' import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { PACK_INDEX_MAINTENANCE_COOLDOWN_MS } from '../../shared/repo-pack-index-maintenance-policy' import { _resetLocalRepoRefMaintenanceForTests, createLocalRepoRefMaintenanceTarget, @@ -12,6 +13,7 @@ import { setRepoMaintenanceActivityProbe } from './local-repo-ref-maintenance' import { forceDeleteLocalBranch } from './worktree-branch-removal' +import { maintainRepoPackIndex, PACK_INDEX_THRESHOLD } from './repo-pack-index-maintenance' const roots: string[] = [] // Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts @@ -19,14 +21,51 @@ const roots: string[] = [] const QUIET_MS = 25 const THRESHOLD = 20 -function git(cwd: string, args: string[]): string { +function git(cwd: string, args: string[], input?: string): string { return execFileSync('git', args, { cwd, encoding: 'utf8', + input, stdio: ['pipe', 'pipe', 'pipe'] }).trim() } +function hasWriteOption(option: string): boolean { + try { + git(process.cwd(), ['multi-pack-index', 'write', '-h']) + } catch (error) { + if (typeof error === 'object' && error !== null) { + return ( + ('stderr' in error && String(error.stderr).includes(option)) || + ('stdout' in error && String(error.stdout).includes(option)) + ) + } + } + return false +} + +async function createFragmentedPacks(repoPath: string): Promise { + const objects = join(repoPath, '.git', 'objects') + const blobs: string[] = [] + for (let index = 0; index < PACK_INDEX_THRESHOLD; index += 1) { + const blob = git(repoPath, ['hash-object', '-w', '--stdin'], `packed-${index}\n`) + blobs.push(blob) + git(repoPath, ['pack-objects', join(objects, 'pack', 'pack')], `${blob}\n`) + await rm(join(objects, blob.slice(0, 2), blob.slice(2))) + } + return blobs +} + +function maintainIndex(repoPath: string) { + return maintainRepoPackIndex({ + repoPath, + commonDir: join(repoPath, '.git'), + signal: new AbortController().signal, + span: { setAttribute: () => {} }, + canWrite: () => true + }) +} + /** A repo whose only loose-ref backlog is the one the test asks for. */ async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> { const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-')) @@ -49,13 +88,17 @@ async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDi return { repoPath, refsDir: join(repoPath, '.git', 'refs') } } -function createMaintenance(onPackRefs: () => void = () => {}): { +function createMaintenance( + onPackRefs: () => void = () => {}, + now?: () => number +): { maintenance: RepoRefMaintenance arm: (repoPath: string) => void } { const maintenance = new RepoRefMaintenance({ quietPeriodMs: QUIET_MS, - looseRefThreshold: THRESHOLD + looseRefThreshold: THRESHOLD, + ...(now ? { now } : {}) }) return { maintenance, @@ -99,6 +142,117 @@ afterEach(async () => { }) describe('idle ref maintenance against real Git', () => { + it('indexes fragmented packs without rewriting objects or requiring loose-ref debt', async () => { + const { repoPath } = await createRepo(0) + git(repoPath, ['config', 'maintenance.auto', 'true']) + git(repoPath, ['config', 'gc.auto', '6700']) + const objects = join(repoPath, '.git', 'objects') + const packs = join(objects, 'pack') + const blobs = await createFragmentedPacks(repoPath) + const originalPacks = (await readdir(packs)).sort() + const lock = join(packs, 'multi-pack-index.lock') + await writeFile(lock, 'another writer') + const blocked = createMaintenance() + blocked.arm(repoPath) + await settle(blocked.maintenance) + blocked.maintenance.dispose() + await expect(readFile(lock, 'utf8')).resolves.toBe('another writer') + await expect(readFile(join(packs, 'multi-pack-index'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + await rm(lock) + const { maintenance, arm } = createMaintenance() + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + const index = await readFile(join(packs, 'multi-pack-index')) + expect(index.subarray(0, 4).toString()).toBe('MIDX') + expect((await readdir(packs)).filter((name) => name !== 'multi-pack-index').sort()).toEqual( + originalPacks + ) + expect(git(repoPath, ['multi-pack-index', 'verify'])).toBe('') + expect(git(repoPath, ['-c', 'core.multiPackIndex=true', 'cat-file', '-p', blobs[0]])).toBe( + 'packed-0' + ) + expect( + git(repoPath, [ + '-c', + 'core.multiPackIndex=true', + 'cat-file', + '-p', + blobs[PACK_INDEX_THRESHOLD - 1] + ]) + ).toBe(`packed-${PACK_INDEX_THRESHOLD - 1}`) + await expect(readFile(join(repoPath, '.git', 'packed-refs'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('refreshes new packs during ref cooldown and keeps readers working between writes', async () => { + const { repoPath } = await createRepo(0) + const blobs = await createFragmentedPacks(repoPath) + const packs = join(repoPath, '.git', 'objects', 'pack') + let clock = 0 + const { maintenance, arm } = createMaintenance( + () => {}, + () => clock + ) + arm(repoPath) + await settle(maintenance) + expect((await readFile(join(packs, 'multi-pack-index'))).readUInt32BE(8)).toBe( + PACK_INDEX_THRESHOLD + ) + const added = git(repoPath, ['hash-object', '-w', '--stdin'], 'new fetched object\n') + git(repoPath, ['pack-objects', join(packs, 'pack')], `${added}\n`) + await rm(join(repoPath, '.git', 'objects', added.slice(0, 2), added.slice(2))) + expect(git(repoPath, ['cat-file', '-p', added])).toBe('new fetched object') + expect(git(repoPath, ['cat-file', '-p', blobs[0]])).toBe('packed-0') + clock = PACK_INDEX_MAINTENANCE_COOLDOWN_MS + 1 + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + expect((await readFile(join(packs, 'multi-pack-index'))).readUInt32BE(8)).toBe( + PACK_INDEX_THRESHOLD + 1 + ) + expect(git(repoPath, ['multi-pack-index', 'verify'])).toBe('') + await expect(readFile(join(repoPath, '.git', 'packed-refs'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it.skipIf(!hasWriteOption('bitmap'))( + 'preserves a real MIDX bitmap and its index byte for byte', + async () => { + const { repoPath } = await createRepo(0) + await createFragmentedPacks(repoPath) + const packs = join(repoPath, '.git', 'objects', 'pack') + git(repoPath, ['pack-objects', '--revs', join(packs, 'pack')], 'HEAD\n') + git(repoPath, ['multi-pack-index', 'write', '--bitmap']) + const metadata = (await readdir(packs)).filter((name) => name.startsWith('multi-pack-index')) + expect(metadata.some((name) => name.endsWith('.bitmap'))).toBe(true) + const before = await Promise.all(metadata.map((name) => readFile(join(packs, name)))) + await expect(maintainIndex(repoPath)).resolves.toBe('protected') + const after = await Promise.all(metadata.map((name) => readFile(join(packs, name)))) + expect(after).toEqual(before) + } + ) + + it.skipIf(!hasWriteOption('incremental'))( + 'preserves a real incremental MIDX chain and its layers', + async () => { + const { repoPath } = await createRepo(0) + await createFragmentedPacks(repoPath) + const chain = join(repoPath, '.git', 'objects', 'pack', 'multi-pack-index.d') + git(repoPath, ['multi-pack-index', 'write', '--incremental']) + const metadata = (await readdir(chain)).sort() + expect(metadata).toContain('multi-pack-index-chain') + const before = await Promise.all(metadata.map((name) => readFile(join(chain, name)))) + await expect(maintainIndex(repoPath)).resolves.toBe('protected') + expect((await readdir(chain)).sort()).toEqual(metadata) + expect(await Promise.all(metadata.map((name) => readFile(join(chain, name))))).toEqual(before) + } + ) + it('packs a backlogged repository down to zero loose refs', async () => { const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) const { maintenance, arm } = createMaintenance() diff --git a/src/main/git/worktree-create-admission-tier.test.ts b/src/main/git/worktree-create-admission-tier.test.ts index f84311f14f6..84bcce00237 100644 --- a/src/main/git/worktree-create-admission-tier.test.ts +++ b/src/main/git/worktree-create-admission-tier.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WorktreePreparationLock from './worktree-preparation-lock' type GitExec = ( args: string[], @@ -8,6 +9,12 @@ type GitExec = ( const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('./worktree-preparation-lock', async (importOriginal) => ({ + ...(await importOriginal()), + verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'), + verifyWorktreePreparationLockAtPath: vi.fn(), + unlockWorktreePreparationAtPath: vi.fn() +})) import { addWorktree } from './worktree-add' import { listWorktreesSharedStrict } from './worktree-scan-cache' @@ -51,11 +58,18 @@ describe('worktree create admission tier', () => { }) it('runs the prepared-checkout finalize at the tier the caller asked for', async () => { - await finalizePreparedWorktree('/repo', '/prepared', '/repo-wt', 'feature', 'main', false, { - admissionTier: 'interactive' - }) + await finalizePreparedWorktree( + '/repo', + '/prepared', + '/repo-wt', + 'feature', + 'main', + false, + { admissionTier: 'interactive' }, + 'owner' + ) - for (const match of ['worktree move', 'checkout --no-track', 'worktree unlock']) { + for (const match of ['worktree move', 'checkout --no-track']) { const options = optionsForCommand(match) expect(options, match).toHaveLength(1) expect(options[0], match).toMatchObject({ admissionTier: 'interactive' }) diff --git a/src/main/git/worktree-create-git-executor-real-git.test.ts b/src/main/git/worktree-create-git-executor-real-git.test.ts index 4aa5322e7eb..02989fc995e 100644 --- a/src/main/git/worktree-create-git-executor-real-git.test.ts +++ b/src/main/git/worktree-create-git-executor-real-git.test.ts @@ -81,7 +81,10 @@ it('creates cold and prepared worktrees with real Git while status capacity is o prepared.preparedPath, join(root, 'warm'), 'warm', - 'main' + 'main', + false, + {}, + prepared.lockReason ) expect(await listWorktrees(repo)).toHaveLength(3) }) diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index 08795fcab1f..7233cc681fc 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -55,6 +55,241 @@ afterEach(async () => { }) describe('prepared worktree creation with real Git', () => { + it('registers during fetch and materializes its settled tip only once before the final hook', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared-barrier') + const finalPath = join(root, 'final-barrier') + const base = 'refs/remotes/origin/main' + const reason = createWorktreePreparationLockReason('barrier-tip') + const originalHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['update-ref', base, originalHead]) + const hooksPath = join(root, 'hooks') + await mkdir(hooksPath) + await writeFile( + join(hooksPath, 'post-checkout'), + '#!/bin/sh\nprintf \'%s\\n\' "$@" >> checkout-hook.txt\n', + { mode: 0o755 } + ) + git(repoPath, ['config', 'core.hooksPath', hooksPath]) + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') + const preparing = prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + base, + reason, + {}, + barrier + ) + await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true)) + await expect(readFile(join(preparedPath, 'version.txt'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + await writeFile(join(repoPath, 'version.txt'), 'fetched\n') + git(repoPath, ['commit', '--quiet', '-am', 'fetched tip']) + const fetchedHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['update-ref', base, fetchedHead]) + release() + try { + await preparing + expect(git(preparedPath, ['rev-parse', 'HEAD'])).toBe(fetchedHead) + expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('fetched\n') + expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false) + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/barrier', + base, + false, + {}, + reason + ) + const resets = spy.mock.calls.filter(([args]) => args.includes('reset')) + expect(resets).toHaveLength(1) + expect(resets[0]?.[0].at(-1)).toBe(fetchedHead) + expect(await readFile(join(finalPath, 'checkout-hook.txt'), 'utf8')).toBe( + `${fetchedHead}\n${fetchedHead}\n1\n` + ) + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/barrier') + await rm(join(finalPath, 'checkout-hook.txt')) + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + } finally { + release() + spy.mockRestore() + } + }) + + it('cancels a never-settling fetch barrier and cleans the registered checkout before it resolves', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'canceled-barrier') + const reason = createWorktreePreparationLockReason('barrier-cancel') + const controller = new AbortController() + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') + const preparing = prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + reason, + { signal: controller.signal }, + barrier + ) + const assertion = expect(preparing).rejects.toThrow('expired while fetching') + try { + await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true)) + const lock = git(preparedPath, ['rev-parse', '--git-path', 'locked']) + await vi.waitFor(async () => expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`)) + controller.abort(new Error('expired while fetching')) + await assertion + expect(existsSync(preparedPath)).toBe(false) + expect(git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain(preparedPath) + release() + await Promise.resolve() + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + } finally { + release() + spy.mockRestore() + } + }) + + it('preserves a replacement owner after the fetch barrier before probing or materializing', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'replaced-barrier') + const reason = createWorktreePreparationLockReason('barrier-replacement') + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const preparing = prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + reason, + {}, + barrier + ) + const assertion = expect(preparing).rejects.toThrow('lock owner changed') + await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true)) + const lock = git(preparedPath, ['rev-parse', '--git-path', 'locked']) + await vi.waitFor(async () => expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`)) + await writeFile(lock, 'manual barrier owner\n') + await writeFile(join(preparedPath, 'version.txt'), 'manual content\n') + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') + try { + release() + await assertion + expect(spy).not.toHaveBeenCalled() + expect(await readFile(lock, 'utf8')).toBe('manual barrier owner\n') + expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('manual content\n') + } finally { + release() + spy.mockRestore() + } + }) + + it('reports an unrelated barrier failure and removes only its owned registration', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'failed-barrier') + const failure = new Error('unexpected barrier failure') + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') + try { + await expect( + prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('barrier-failure'), + {}, + Promise.reject(failure) + ) + ).rejects.toBe(failure) + expect(existsSync(preparedPath)).toBe(false) + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + expect(git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain(preparedPath) + } finally { + spy.mockRestore() + } + }) + + it('pins first materialization to the resolved fetched OID when the ref moves before reset', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'moving-barrier-tip') + const originalHead = git(repoPath, ['rev-parse', 'HEAD']) + await writeFile(join(repoPath, 'version.txt'), 'newer\n') + git(repoPath, ['commit', '--quiet', '-am', 'later tip']) + const newerHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', originalHead]) + const run = gitRunner.gitExecFileAsync + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => { + if (args.includes('reset')) { + git(repoPath, ['update-ref', 'refs/remotes/origin/main', newerHead]) + } + return run(args, options) + }) + try { + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'refs/remotes/origin/main', + createWorktreePreparationLockReason('barrier-oid-race'), + {}, + Promise.resolve() + ) + expect(git(preparedPath, ['rev-parse', 'HEAD'])).toBe(originalHead) + expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n') + expect(spy.mock.calls.find(([args]) => args.includes('reset'))?.[0].at(-1)).toBe(originalHead) + } finally { + spy.mockRestore() + } + }) + + it('rechecks the barrier marker after reading the fresh commit before the first reset', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'replaced-after-tip-read') + const reason = createWorktreePreparationLockReason('barrier-tip-owner') + const run = gitRunner.gitExecFileAsync + let lock = '' + const spy = vi + .spyOn(gitRunner, 'gitExecFileAsync') + .mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args[0] === 'rev-parse' && args.includes('refs/heads/main^{commit}')) { + lock = git(preparedPath, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual after tip read\n') + } + return result + }) + try { + await expect( + prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'refs/heads/main', + reason, + {}, + Promise.resolve() + ) + ).rejects.toThrow('lock owner changed') + expect( + spy.mock.calls.some(([args]) => args.includes('reset') || args.includes('remove')) + ).toBe(false) + expect(await readFile(lock, 'utf8')).toBe('manual after tip read\n') + await expect(readFile(join(preparedPath, 'version.txt'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + } finally { + spy.mockRestore() + } + }) + it.each([false, true])( 'attaches the prepared HEAD and runs the hook (base advanced: %s)', async (advanceBase) => { @@ -70,19 +305,24 @@ describe('prepared worktree creation with real Git', () => { ) git(repoPath, ['config', 'core.hooksPath', hooksPath]) git(repoPath, ['config', 'branch.autoSetupMerge', 'always']) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('attach-with-hook') - ) + const lockReason = createWorktreePreparationLockReason('attach-with-hook') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false) if (advanceBase) { await writeFile(join(repoPath, 'version.txt'), 'advanced\n') git(repoPath, ['commit', '--quiet', '-am', 'advance base']) } const targetHead = git(repoPath, ['rev-parse', 'HEAD']) - await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/attached', 'main') + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/attached', + 'main', + false, + {}, + lockReason + ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(targetHead) expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/attached') @@ -104,12 +344,8 @@ describe('prepared worktree creation with real Git', () => { const { repoPath, root } = await createRepo() const preparedPath = join(root, 'prepared-race') const finalPath = join(root, 'final-race') - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('head-race') - ) + const lockReason = createWorktreePreparationLockReason('head-race') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) const expectedHead = git(repoPath, ['rev-parse', 'HEAD']) git(repoPath, ['checkout', '--quiet', '-b', 'other']) await writeFile(join(repoPath, 'version.txt'), 'other\n') @@ -125,7 +361,16 @@ describe('prepared worktree creation with real Git', () => { return original(args, options) }) try { - await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/race', 'main') + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/race', + 'main', + false, + {}, + lockReason + ) } finally { spy.mockRestore() } @@ -147,15 +392,20 @@ describe('prepared worktree creation with real Git', () => { { mode: 0o755 } ) git(repoPath, ['config', 'core.hooksPath', hooksPath]) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('hook-commit') - ) + const lockReason = createWorktreePreparationLockReason('hook-commit') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) const baseHead = git(repoPath, ['rev-parse', 'HEAD']) - await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-commit', 'main') + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/hook-commit', + 'main', + false, + {}, + lockReason + ) expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/hook-commit') expect(git(finalPath, ['rev-parse', 'HEAD^'])).toBe(baseHead) @@ -172,15 +422,20 @@ describe('prepared worktree creation with real Git', () => { await mkdir(hooksPath) await writeFile(join(hooksPath, 'post-checkout'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }) git(repoPath, ['config', 'core.hooksPath', hooksPath]) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('hook-failure') - ) + const lockReason = createWorktreePreparationLockReason('hook-failure') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) await expect( - finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-failure', 'main') + finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/hook-failure', + 'main', + false, + {}, + lockReason + ) ).rejects.toThrow() expect(existsSync(finalPath)).toBe(false) expect(git(repoPath, ['branch', '--list', 'feature/hook-failure'])).toBe('') @@ -202,7 +457,7 @@ describe('prepared worktree creation with real Git', () => { return original(args, options) }) try { - await expect(discardPreparedWorktree(repoPath, preparedPath)).rejects.toThrow( + await expect(discardPreparedWorktree(repoPath, preparedPath, {}, lockReason)).rejects.toThrow( 'injected removal launch failure' ) const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true }) @@ -214,7 +469,7 @@ describe('prepared worktree creation with real Git', () => { expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n') } finally { spy.mockRestore() - await discardPreparedWorktree(repoPath, preparedPath) + await discardPreparedWorktree(repoPath, preparedPath, {}, lockReason) } expect(existsSync(preparedPath)).toBe(false) }) @@ -265,7 +520,16 @@ describe('prepared worktree creation with real Git', () => { expect(entry).toBeDefined() takePreparation(entry) const finalPath = join(root, 'fresh-worktree') - await finalizePreparedWorktree(repoPath, entry.preparedPath, finalPath, 'fresh', 'main') + await finalizePreparedWorktree( + repoPath, + entry.preparedPath, + finalPath, + 'fresh', + 'main', + false, + {}, + entry.lockReason + ) expect(git(finalPath, ['status', '--porcelain'])).toBe('') expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('fresh') expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('one\n') @@ -346,17 +610,13 @@ describe('prepared worktree creation with real Git', () => { const preparedPath = join(preparationRoot, `${process.pid}-canceled`) await mkdir(preparationRoot, { recursive: true }) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('canceled-test') - ) + const lockReason = createWorktreePreparationLockReason('canceled-test') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) const controller = new AbortController() controller.abort() await expect( - discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal }) + discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal }, lockReason) ).resolves.toBeUndefined() expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1) @@ -393,7 +653,16 @@ describe('prepared worktree creation with real Git', () => { ) expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead) - await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main') + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/retargeted', + 'main', + false, + {}, + createWorktreePreparationLockReason('retarget-test') + ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead) // A retarget that left stale files behind would be a wrong checkout, not just a slow one. @@ -452,7 +721,10 @@ describe('prepared worktree creation with real Git', () => { preparedPath, finalPath, 'feature/overlap', - 'refs/remotes/origin/main' + 'refs/remotes/origin/main', + false, + {}, + createWorktreePreparationLockReason('fetch-overlap') ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(refreshed) expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('refreshed\n') @@ -497,7 +769,9 @@ describe('prepared worktree creation with real Git', () => { finalPath, 'feature/prepared', 'main', - false + false, + {}, + createWorktreePreparationLockReason('real-git-test') ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(latestHead) diff --git a/src/main/git/worktree-create-preparation-real-wsl.test.ts b/src/main/git/worktree-create-preparation-real-wsl.test.ts index 8ccaf3c23bf..50f46342502 100644 --- a/src/main/git/worktree-create-preparation-real-wsl.test.ts +++ b/src/main/git/worktree-create-preparation-real-wsl.test.ts @@ -4,10 +4,10 @@ import { expect, it } from 'vitest' import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' import { gitExecFileAsync } from './runner' import { - discardPreparedWorktree, finalizePreparedWorktree, prepareWorktreeCreateCheckout } from './worktree-create-preparation' +import { removeWorktree } from './worktree-removal' // Opt in on Windows with a running distro; all Git commands use the production WSL router. const wslDistro = process.env.ORCA_TEST_WSL_DISTRO @@ -34,13 +34,8 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)( await writeFile(join(repoPath, 'version.txt'), 'one\n') await git(repoPath, ['add', 'version.txt']) await git(repoPath, ['commit', '--quiet', '-m', 'initial']) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('real-wsl-test'), - options - ) + const lockReason = createWorktreePreparationLockReason('real-wsl-test') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason, options) expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).toContain( 'locked orca-create-preparation:v1:' ) @@ -55,7 +50,8 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)( 'feature/routed', 'main', false, - options + options, + lockReason ) expect(await git(finalPath, ['rev-parse', 'HEAD'])).toBe(target) expect(await git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/routed') @@ -65,7 +61,7 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)( 'refs/heads/main' ) expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain('locked ') - await discardPreparedWorktree(repoPath, finalPath, options) + await removeWorktree(repoPath, finalPath, true, options) expect( (await git(repoPath, ['worktree', 'list', '--porcelain'])).match(/^worktree /gm) ).toHaveLength(1) diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index 5a4f65314af..cb51e1ca8ae 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -1,7 +1,8 @@ import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import type { AddWorktreeOptions, AddWorktreeResult, GitWorktreeExecOptions } from './worktree' -import { gitExecOptions, type GitExecOptionsForWorktree } from './worktree-operation-options' +import { gitExecOptions } from './worktree-operation-options' import { configurePushAutoSetupRemote, notifyPreparedWorktreeMutation, @@ -10,93 +11,77 @@ import { resolveWorktreeAddTimeoutMs, WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' +import { + gitCleanupOptions, + performDiscardPreparedWorktree, + removeFailedFinalization +} from './worktree-preparation-discard' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' - -function gitCleanupOptions( - cwd: string, - options: GitWorktreeExecOptions -): GitExecOptionsForWorktree { - // Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately. - return gitExecOptions(cwd, { ...options, signal: undefined }) -} - -async function performDiscardPreparedWorktree( - repoPath: string, - worktreePath: string, - options: GitWorktreeExecOptions -): Promise { - const cleanupGitOptions = { - ...gitCleanupOptions(repoPath, options), - timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS - } - try { - // Preserve the ownership lock if removal cannot start; Git 2.25 supports locked removal. - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'remove', - '--force', - '--force', - worktreePath - ], - cleanupGitOptions - ) - } finally { - invalidateWslLinkedWorktreeGitRouting(worktreePath) - } -} +import { + unlockWorktreePreparation, + unlockWorktreePreparationAtPath, + verifyWorktreePreparationLock, + verifyWorktreePreparationLockAtPath, + WorktreePreparationLockOwnershipError +} from './worktree-preparation-lock' +import { addLockedWorktreePreparation } from './worktree-preparation-add' export async function prepareWorktreeCreateCheckout( repoPath: string, worktreePath: string, baseBranch: string, lockReason: string, - options: GitWorktreeExecOptions = {} + options: GitWorktreeExecOptions = {}, + beforeMaterialization?: Promise ): Promise { + // Observe early rejection while registration runs; awaiting still reports the original error. + void beforeMaterialization?.catch(() => {}) try { await withRepoRefMaintenancePaused('worktree-prepare', () => runWithGitReadCacheInvalidation(async () => { const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) + let lockPath: string | undefined try { - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'add', - '--detach', - '--no-checkout', - worktreePath, - effectiveBase - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + lockPath = await addLockedWorktreePreparation( + repoPath, + worktreePath, + effectiveBase, + lockReason, + { + ...options, + timeout: resolveWorktreeAddTimeoutMs() + } ) - // The add just wrote the marker; drop any pre-create route before the reset routes Git. - invalidateWslLinkedWorktreeGitRouting(worktreePath) + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) + let materializationBase = effectiveBase + if (beforeMaterialization) { + await waitForPromiseWithSignal(beforeMaterialization, options.signal) + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', `${effectiveBase}^{commit}`], + gitExecOptions(repoPath, options) + ) + materializationBase = stdout.trim() + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) + } // Why: reset materializes files without running user post-checkout hooks before submit. await gitExecFileAsync( - [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], + [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', materializationBase], { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } ) - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'lock', - '--reason', - lockReason, - worktreePath - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) } catch (error) { - await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) + if (lockPath !== undefined && !(error instanceof WorktreePreparationLockOwnershipError)) { + await performDiscardPreparedWorktree(repoPath, worktreePath, options, lockReason).catch( + () => {} + ) + } throw error } }) @@ -109,11 +94,12 @@ export async function prepareWorktreeCreateCheckout( export async function discardPreparedWorktree( repoPath: string, worktreePath: string, - options: GitWorktreeExecOptions = {} + options: GitWorktreeExecOptions = {}, + expectedLockReason: string ): Promise { try { await runWithGitReadCacheInvalidation(() => - performDiscardPreparedWorktree(repoPath, worktreePath, options) + performDiscardPreparedWorktree(repoPath, worktreePath, options, expectedLockReason) ) } finally { notifyPreparedWorktreeMutation(repoPath) @@ -123,52 +109,22 @@ export async function discardPreparedWorktree( export async function unlockPreparedWorktree( repoPath: string, worktreePath: string, - options: GitWorktreeExecOptions = {} + options: GitWorktreeExecOptions = {}, + expectedLockReason: string ): Promise { const cleanupGitOptions = { ...gitCleanupOptions(repoPath, options), timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } try { - await runWithGitReadCacheInvalidation(() => - gitExecFileAsync( - [...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath], - cleanupGitOptions - ) - ) + await runWithGitReadCacheInvalidation(async () => { + await unlockWorktreePreparation(worktreePath, expectedLockReason, cleanupGitOptions) + }) } finally { notifyPreparedWorktreeMutation(repoPath) } } -async function removeFailedFinalization( - repoPath: string, - cleanupPath: string, - branch: string, - moved: boolean, - options: GitWorktreeExecOptions -): Promise { - let branchAttached = false - if (moved) { - try { - const { stdout } = await gitExecFileAsync( - ['symbolic-ref', '--short', 'HEAD'], - gitCleanupOptions(cleanupPath, options) - ) - branchAttached = stdout.trim() === branch - } catch { - // Detached or no longer readable. - } - } - await performDiscardPreparedWorktree(repoPath, cleanupPath, options).catch(() => {}) - if (branchAttached) { - await gitExecFileAsync( - ['branch', '-D', '--', branch], - gitCleanupOptions(repoPath, options) - ).catch(() => {}) - } -} - export async function finalizePreparedWorktree( repoPath: string, preparedPath: string, @@ -176,7 +132,8 @@ export async function finalizePreparedWorktree( branch: string, baseBranch: string, refreshLocalBaseRef = false, - options: AddWorktreeOptions = {} + options: AddWorktreeOptions = {}, + expectedLockReason: string ): Promise { const finalizeGitOptions: AddWorktreeOptions = { ...options, @@ -184,6 +141,13 @@ export async function finalizePreparedWorktree( } try { return await runWithGitReadCacheInvalidation(async () => { + const lockPath = await verifyWorktreePreparationLock( + preparedPath, + expectedLockReason, + finalizeGitOptions + ) + const verifyOwnership = (): Promise => + verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, finalizeGitOptions.signal) const [targetResult, preparedResult] = await Promise.allSettled([ (async () => { const baseContext = await resolveWorktreeAddBaseContext( @@ -219,6 +183,7 @@ export async function finalizePreparedWorktree( } const preparedHeadOutput = preparedResult.value.stdout if (preparedHeadOutput.trim() !== targetHead) { + await verifyOwnership() await gitExecFileAsync( [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], gitExecOptions(preparedPath, finalizeGitOptions) @@ -228,6 +193,7 @@ export async function finalizePreparedWorktree( let moved = false try { try { + await verifyOwnership() // Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25). await gitExecFileAsync( [ @@ -247,6 +213,7 @@ export async function finalizePreparedWorktree( invalidateWslLinkedWorktreeGitRouting(preparedPath) invalidateWslLinkedWorktreeGitRouting(worktreePath) } + await verifyOwnership() await gitExecFileAsync( [ ...windowsLongPathGitArgs(worktreePath), @@ -258,6 +225,7 @@ export async function finalizePreparedWorktree( ], gitExecOptions(worktreePath, finalizeGitOptions) ) + await verifyOwnership() await persistWorktreeCreationBase( worktreePath, branch, @@ -265,18 +233,22 @@ export async function finalizePreparedWorktree( finalizeGitOptions ) await configurePushAutoSetupRemote(worktreePath, finalizeGitOptions) - await gitExecFileAsync( - [...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath], - gitExecOptions(repoPath, finalizeGitOptions) + await unlockWorktreePreparationAtPath( + lockPath, + expectedLockReason, + finalizeGitOptions.signal ) } catch (error) { - await removeFailedFinalization( - repoPath, - moved ? worktreePath : preparedPath, - branch, - moved, - finalizeGitOptions - ) + if (!(error instanceof WorktreePreparationLockOwnershipError)) { + await removeFailedFinalization( + repoPath, + moved ? worktreePath : preparedPath, + branch, + moved, + finalizeGitOptions, + expectedLockReason + ) + } await baseContext.pendingLocalBaseRefRefresh throw error } diff --git a/src/main/git/worktree-mutation-route-invalidation.test.ts b/src/main/git/worktree-mutation-route-invalidation.test.ts index 785ed3d1391..48db9133114 100644 --- a/src/main/git/worktree-mutation-route-invalidation.test.ts +++ b/src/main/git/worktree-mutation-route-invalidation.test.ts @@ -1,6 +1,7 @@ // Worktree add/move/remove/rollback rewrite the `.git` marker the WSL Git route was derived from. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WorktreeModule from './worktree' +import type * as WorktreePreparationLock from './worktree-preparation-lock' const { gitExecFileAsyncMock, @@ -38,6 +39,14 @@ vi.mock('./worktree-scan-cache', () => ({ listWorktrees: listWorktreesMock })) +vi.mock('./worktree-preparation-lock', async (importOriginal) => ({ + ...(await importOriginal()), + lockWorktreePreparation: vi.fn(async () => '/owned-lock'), + verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'), + verifyWorktreePreparationLockAtPath: vi.fn(), + unlockWorktreePreparationAtPath: vi.fn() +})) + import { addWorktree } from './worktree-add' import { discardPreparedWorktree, @@ -120,7 +129,7 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () = it('drops the route after a prepared worktree is discarded', async () => { seedWslLinkedWorktreeGitRoutingForTests(LINKED) - await discardPreparedWorktree(REPO, LINKED) + await discardPreparedWorktree(REPO, LINKED, {}, 'owner') expect(hasCachedHostRoute(LINKED)).toBe(false) }) @@ -133,11 +142,41 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () = expect(hasCachedHostRoute(PREPARED)).toBe(false) }) + it('reads and materializes the barrier tip on the preparation Git host', async () => { + const head = 'b'.repeat(40) + gitExecFileAsyncMock.mockResolvedValue({ stdout: `${head}\n`, stderr: '' }) + await prepareWorktreeCreateCheckout( + REPO, + PREPARED, + 'refs/remotes/origin/main', + 'owner', + { wslDistro: 'Ubuntu' }, + Promise.resolve() + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], + { cwd: REPO, wslDistro: 'Ubuntu' } + ) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['reset', '--hard', head], + expect.objectContaining({ cwd: PREPARED, wslDistro: 'Ubuntu' }) + ) + }) + it('drops both routes after the prepared checkout is moved into place', async () => { seedWslLinkedWorktreeGitRoutingForTests(PREPARED) seedWslLinkedWorktreeGitRoutingForTests(LINKED) - await finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main') + await finalizePreparedWorktree( + REPO, + PREPARED, + LINKED, + 'feature', + 'origin/main', + false, + {}, + 'owner' + ) expect(hasCachedHostRoute(PREPARED)).toBe(false) expect(hasCachedHostRoute(LINKED)).toBe(false) @@ -153,7 +192,7 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () = ) await expect( - finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main') + finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main', false, {}, 'owner') ).rejects.toThrow('destination exists') expect(hasCachedHostRoute(PREPARED)).toBe(false) diff --git a/src/main/git/worktree-preparation-add.test.ts b/src/main/git/worktree-preparation-add.test.ts new file mode 100644 index 00000000000..f96db71aebb --- /dev/null +++ b/src/main/git/worktree-preparation-add.test.ts @@ -0,0 +1,332 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type * as FilePromises from 'node:fs/promises' +import type * as PreparationLock from './worktree-preparation-lock' +import { isUnsupportedWorktreeAddLockReasonError } from '../../shared/git-worktree-command-capabilities' + +const mocks = vi.hoisted(() => ({ + git: vi.fn(), + lstat: vi.fn(), + lock: vi.fn(), + verify: vi.fn(), + discard: vi.fn() +})) +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + lstat: mocks.lstat +})) +vi.mock('./worktree-preparation-lock', async (importOriginal) => ({ + ...(await importOriginal()), + lockWorktreePreparation: mocks.lock, + verifyWorktreePreparationLock: mocks.verify +})) +vi.mock('./worktree-preparation-discard', () => ({ performDiscardPreparedWorktree: mocks.discard })) + +import { addLockedWorktreePreparation } from './worktree-preparation-add' +import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache } from './git-capability-state' +import { WorktreePreparationLockOwnershipError } from './worktree-preparation-lock' +import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree-operation-options' +import { + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests +} from './wsl-linked-worktree-git-routing' + +const reason = 'orca-create-preparation:v1:123:atomic' +const unsupported = Object.assign(new Error("error: unknown option 'reason'"), { code: 129 }) +const prepare = (path = '/prepared', options = {}) => + addLockedWorktreePreparation('/repo', path, 'refs/heads/main', reason, options) + +beforeEach(() => { + clearGitCapabilityStateForTests() + resetWslLinkedWorktreeGitRoutingForTests() + mocks.git.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + mocks.lstat.mockReset().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + mocks.lock.mockReset().mockResolvedValue('/fallback-lock') + mocks.verify.mockReset().mockResolvedValue('/atomic-lock') + mocks.discard.mockReset().mockResolvedValue(undefined) +}) + +it('asks Git to create the exact marker atomically and only verifies its ownership', async () => { + const controller = new AbortController() + const options = { signal: controller.signal, timeout: 6000, admissionTier: 'background' as const } + await expect(prepare('/prepared', options)).resolves.toBe('/atomic-lock') + expect(mocks.git).toHaveBeenCalledExactlyOnceWith( + [ + 'worktree', + 'add', + '--detach', + '--no-checkout', + '--lock', + '--reason', + reason, + '/prepared', + 'refs/heads/main' + ], + { cwd: '/repo', ...options } + ) + expect(mocks.verify).toHaveBeenCalledExactlyOnceWith('/prepared', reason, options) + expect(mocks.lock).not.toHaveBeenCalled() +}) + +it('falls back once on the old-Git reason rejection and caches the absence', async () => { + mocks.git.mockRejectedValueOnce(unsupported) + await expect(prepare()).resolves.toBe('/fallback-lock') + await expect(prepare('/second')).resolves.toBe('/fallback-lock') + expect(mocks.git.mock.calls.map(([args]) => args.includes('--reason'))).toEqual([ + true, + false, + false + ]) + expect(mocks.lock.mock.calls.map(([path]) => path)).toEqual(['/prepared', '/second']) + expect(mocks.verify).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it.each([ + ['first', 'cancellation'], + ['cached', 'cancellation'], + ['first', 'path probe'], + ['cached', 'path probe'], + ['first', 'marker write'], + ['cached', 'marker write'] +])('cleans its successful %s fallback add after a %s failure', async (fallback, failureKind) => { + const controller = new AbortController() + const options = { + signal: controller.signal, + timeout: 180_000, + wslDistro: 'Ubuntu', + admissionTier: 'background' as const + } + if (fallback === 'cached') { + getLocalGitCapabilityCache(options).rememberUnsupported('worktree-add-lock-reason') + } else { + mocks.git.mockRejectedValueOnce(unsupported) + } + const failure = new Error(`${failureKind} failed after registration`) + mocks.lock.mockImplementationOnce(async () => { + if (failureKind === 'cancellation') { + controller.abort(failure) + } + throw failure + }) + await expect(prepare('/prepared', options)).rejects.toBe(failure) + expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', { + ...options, + signal: undefined, + timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + }) + expect(mocks.lstat).toHaveBeenCalled() + expect(options.timeout).toBe(180_000) + expect(options.signal).toBe(controller.signal) +}) + +it('preserves the original lock failure when bounded fallback cleanup also fails', async () => { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + const failure = new Error('lock path unavailable') + mocks.lock.mockRejectedValueOnce(failure) + mocks.discard.mockRejectedValueOnce(new Error('cleanup unavailable')) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.discard).toHaveBeenCalledOnce() +}) + +it.each(['first', 'cached'])( + 'preserves a pre-existing target after %s fallback locking fails', + async (fallback) => { + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } else { + mocks.git.mockRejectedValueOnce(unsupported) + } + mocks.lstat.mockResolvedValue({}) + const failure = new Error('marker write denied') + mocks.lock.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.discard).not.toHaveBeenCalled() + } +) + +it.each(['first', 'cached'])( + 'preserves a competing marker during %s fallback locking', + async (fallback) => { + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } else { + mocks.git.mockRejectedValueOnce(unsupported) + } + mocks.lock.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError()) + await expect(prepare()).rejects.toThrow('lock owner changed') + expect(mocks.discard).not.toHaveBeenCalled() + } +) + +it.each(['first', 'cached'])( + 'preserves an incomplete or rejected %s fallback add', + async (fallback) => { + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } else { + mocks.git.mockRejectedValueOnce(unsupported) + } + const failure = new Error('add did not complete') + mocks.git.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.lock).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() + } +) + +it('fails closed before a cached fallback add if the target cannot be inspected', async () => { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + const failure = Object.assign(new Error('target unavailable'), { code: 'EACCES' }) + mocks.lstat.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.git).not.toHaveBeenCalled() + expect(mocks.lock).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it('coalesces concurrent unsupported probes while creating each checkout separately', async () => { + let reject!: (error: unknown) => void + mocks.git.mockImplementationOnce( + () => + new Promise((_resolve, rejectProbe) => { + reject = rejectProbe + }) + ) + const first = prepare('/first') + await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(1)) + const second = prepare('/second') + await Promise.resolve() + expect(mocks.git).toHaveBeenCalledTimes(1) + reject(unsupported) + await expect(Promise.all([first, second])).resolves.toEqual(['/fallback-lock', '/fallback-lock']) + expect(mocks.git.mock.calls.filter(([args]) => args.includes('--reason'))).toHaveLength(1) + expect(new Set(mocks.lock.mock.calls.map(([path]) => path))).toEqual( + new Set(['/first', '/second']) + ) +}) + +it('runs each concurrent supported add rather than sharing the first checkout result', async () => { + let resolve!: (value: { stdout: string }) => void + mocks.git.mockImplementationOnce( + () => + new Promise((resolveProbe) => { + resolve = resolveProbe + }) + ) + mocks.verify.mockImplementation(async (path: string) => `${path}/owned-lock`) + const first = prepare('/first') + await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(1)) + const second = prepare('/second') + resolve({ stdout: '' }) + await expect(Promise.all([first, second])).resolves.toEqual([ + '/first/owned-lock', + '/second/owned-lock' + ]) + expect(mocks.git.mock.calls.every(([args]) => args.includes('--reason'))).toBe(true) + expect(mocks.git).toHaveBeenCalledTimes(2) +}) + +it('isolates native rejection from individual WSL distros', async () => { + mocks.git.mockRejectedValueOnce(unsupported) + await prepare() + await prepare('/ubuntu', { wslDistro: 'Ubuntu' }) + await prepare('/debian', { wslDistro: 'Debian' }) + await prepare('/native-again') + expect(mocks.git.mock.calls.map(([args]) => args.includes('--reason'))).toEqual([ + true, + false, + true, + true, + false + ]) +}) + +it('uses native cached rejection when WSL routing executes the host Git binary', async () => { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + const repoPath = String.raw`C:\repo\linked` + seedWslLinkedWorktreeGitRoutingForTests(repoPath) + await addLockedWorktreePreparation(repoPath, String.raw`C:\prepared`, 'main', reason, { + wslDistro: 'Ubuntu' + }) + expect(mocks.git).toHaveBeenCalledTimes(1) + expect(mocks.git.mock.calls[0][0]).not.toContain('--reason') + expect( + getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }).shouldTry('worktree-add-lock-reason') + ).toBe(true) + } finally { + platform.mockRestore() + } +}) + +it('checks a guest directory through its execution distro rather than the Windows root', async () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + await prepare('/home/prepared', { wslDistro: 'Ubuntu' }) + expect(mocks.lstat).toHaveBeenCalledExactlyOnceWith( + String.raw`\\wsl.localhost\Ubuntu\home\prepared` + ) + } finally { + platform.mockRestore() + } +}) + +it('fails closed when the pre-existing target cannot be inspected', async () => { + const failure = Object.assign(new Error('path access denied'), { code: 'EACCES' }) + mocks.lstat.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.git).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it('keeps a general add failure visible without poisoning the capability or claiming a marker', async () => { + const failure = new Error('permission denied') + mocks.git.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.lock).not.toHaveBeenCalled() + expect(getLocalGitCapabilityCache().shouldTry('worktree-add-lock-reason')).toBe(true) + expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', {}, reason) + await prepare('/next') + expect(mocks.git.mock.calls.every(([args]) => args.includes('--reason'))).toBe(true) +}) + +it('never removes a pre-existing checkout after add fails', async () => { + mocks.lstat.mockResolvedValueOnce({}) + mocks.git.mockRejectedValueOnce(new Error('path already exists')) + await expect(prepare()).rejects.toThrow('path already exists') + expect(mocks.discard).not.toHaveBeenCalled() + expect(mocks.lock).not.toHaveBeenCalled() +}) + +it('does not rewrite or discard a generic or competing marker returned after add', async () => { + mocks.verify.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError()) + await expect(prepare()).rejects.toThrow('lock owner changed') + expect(mocks.lock).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it('attempts ownership-checked cleanup if cancellation interrupts the newly registered add', async () => { + const controller = new AbortController() + const failure = new Error('add canceled') + mocks.git.mockImplementationOnce(async () => { + controller.abort() + throw failure + }) + const options = { signal: controller.signal } + await expect(prepare('/prepared', options)).rejects.toBe(failure) + expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', options, reason) + expect(mocks.lock).not.toHaveBeenCalled() +}) + +it.each([ + [new Error("unknown option 'reason'"), true], + [{ stderr: "error: unrecognized option '--reason'" }, true], + [{ stdout: 'invalid switch --reason' }, true], + [{ code: 129, stderr: "unknown option 'detach'" }, false], + [{ code: 129 }, false], + [new Error('permission denied while writing lock reason'), false] +])('recognizes only an unsupported lock-reason option: %j', (error, expected) => { + expect(isUnsupportedWorktreeAddLockReasonError(error)).toBe(expected) +}) diff --git a/src/main/git/worktree-preparation-add.ts b/src/main/git/worktree-preparation-add.ts new file mode 100644 index 00000000000..bcbeb4dae8f --- /dev/null +++ b/src/main/git/worktree-preparation-add.ts @@ -0,0 +1,119 @@ +import { lstat } from 'node:fs/promises' +import { isUnsupportedWorktreeAddLockReasonError } from '../../shared/git-worktree-command-capabilities' +import { resolveWorktreeHostPath } from '../../shared/git-metadata-path' +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { toHostFilesystemPath } from '../host-tree-removal' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { gitExecFileAsync } from './runner' +import { + getErrorCode, + gitExecOptions, + WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS, + type GitWorktreeExecOptions +} from './worktree-operation-options' +import { performDiscardPreparedWorktree } from './worktree-preparation-discard' +import { + lockWorktreePreparation, + verifyWorktreePreparationLock, + WorktreePreparationLockOwnershipError +} from './worktree-preparation-lock' +import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' + +async function isAbsent(worktreePath: string, options: GitWorktreeExecOptions): Promise { + const hostPath = resolveWorktreeHostPath(worktreePath, options) + if (!hostPath) { + throw new Error('The prepared worktree path is empty') + } + try { + await lstat(toHostFilesystemPath(hostPath)) + return false + } catch (error) { + if (getErrorCode(error) === 'ENOENT') { + return true + } + throw error + } +} + +export function addLockedWorktreePreparation( + repoPath: string, + worktreePath: string, + baseRef: string, + lockReason: string, + options: GitWorktreeExecOptions +): Promise { + const add = async (lockArgs: string[]): Promise => { + try { + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'add', + '--detach', + '--no-checkout', + ...lockArgs, + worktreePath, + baseRef + ], + gitExecOptions(repoPath, options) + ) + } finally { + invalidateWslLinkedWorktreeGitRouting(worktreePath) + } + } + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + async (capabilities) => { + const initiallyAbsent = await isAbsent(worktreePath, options) + return capabilities.runWithFallback( + 'worktree-add-lock-reason', + async () => { + let added = false + try { + await add(['--lock', '--reason', lockReason]) + added = true + return await verifyWorktreePreparationLock(worktreePath, lockReason, options) + } catch (error) { + // A canceled add can leave its marker; a pre-existing checkout is never ours to remove. + if ( + (added || initiallyAbsent) && + !isUnsupportedWorktreeAddLockReasonError(error) && + !(error instanceof WorktreePreparationLockOwnershipError) + ) { + await performDiscardPreparedWorktree( + repoPath, + worktreePath, + options, + lockReason + ).catch(() => {}) + } + throw error + } + }, + async () => { + let added = false + try { + await add([]) + added = true + return await lockWorktreePreparation(worktreePath, lockReason, options) + } catch (error) { + if ( + added && + initiallyAbsent && + !(error instanceof WorktreePreparationLockOwnershipError) + ) { + // Single force reclaims our unlocked add while preserving any competing lock. + await performDiscardPreparedWorktree(repoPath, worktreePath, { + ...options, + signal: undefined, + timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + }).catch(() => {}) + } + throw error + } + }, + isUnsupportedWorktreeAddLockReasonError + ) + } + ) +} diff --git a/src/main/git/worktree-preparation-base-oid.test.ts b/src/main/git/worktree-preparation-base-oid.test.ts index ecd6f4eab19..508707dbd9e 100644 --- a/src/main/git/worktree-preparation-base-oid.test.ts +++ b/src/main/git/worktree-preparation-base-oid.test.ts @@ -1,8 +1,15 @@ import { beforeEach, expect, it, vi } from 'vitest' import type * as WorktreeBaseRefresh from './worktree-base-refresh' +import type * as WorktreePreparationLock from './worktree-preparation-lock' const gitExec = vi.hoisted(() => vi.fn()) vi.mock('./runner', () => ({ gitExecFileAsync: gitExec })) +vi.mock('./worktree-preparation-lock', async (importOriginal) => ({ + ...(await importOriginal()), + verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'), + verifyWorktreePreparationLockAtPath: vi.fn(), + unlockWorktreePreparationAtPath: vi.fn() +})) vi.mock('./worktree-base-refresh', async (importOriginal) => ({ ...(await importOriginal()), refreshLocalBaseRefForWorktreeCreate: vi.fn(async () => undefined), @@ -30,10 +37,16 @@ beforeEach(() => { }) it('reuses the current base-resolution oid and preserves WSL routing', async () => { - await finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main', false, { - wslDistro: 'Ubuntu', - timeout: 8000 - }) + await finalizePreparedWorktree( + '/repo', + '/prepared', + '/final', + 'feature', + 'main', + false, + { wslDistro: 'Ubuntu', timeout: 8000 }, + 'owner' + ) const revisions = gitExec.mock.calls.filter(([args]) => args[0] === 'rev-parse') expect(revisions.map(([args]) => args)).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], @@ -58,7 +71,8 @@ it.each([ 'feature', test.base, test.refresh, - test.options + test.options, + 'owner' ) expect(gitExec).toHaveBeenCalledWith( ['rev-parse', '--verify', 'refs/heads/main^{commit}'], @@ -84,7 +98,16 @@ it('starts both independent probes before either resolves and settles them befor }) let settled = false const error = new Error('prepared HEAD unreadable') - const result = finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main') + const result = finalizePreparedWorktree( + '/repo', + '/prepared', + '/final', + 'feature', + 'main', + false, + {}, + 'owner' + ) const checked = expect(result).rejects.toBe(error) void result.then( () => (settled = true), diff --git a/src/main/git/worktree-preparation-cancel-latency.bench.test.ts b/src/main/git/worktree-preparation-cancel-latency.bench.test.ts index e2750df839d..c5f30b4788b 100644 --- a/src/main/git/worktree-preparation-cancel-latency.bench.test.ts +++ b/src/main/git/worktree-preparation-cancel-latency.bench.test.ts @@ -43,16 +43,28 @@ function nextPreparedPath(label: string): string { return join(preparationRoot, `${process.pid}-${label}-${sequence}`) } +const lockReasons = new Map() + function checkout(preparedPath: string, signal?: AbortSignal): Promise { + const lockReason = createWorktreePreparationLockReason(`bench-${sequence}`) + lockReasons.set(preparedPath, lockReason) return prepareWorktreeCreateCheckout( repoPath, preparedPath, 'main', - createWorktreePreparationLockReason(`bench-${sequence}`), + lockReason, signal ? { signal } : {} ) } +function discard(preparedPath: string): Promise { + const lockReason = lockReasons.get(preparedPath) + if (!lockReason) { + throw new Error('The benchmark checkout has no lock reason') + } + return discardPreparedWorktree(repoPath, preparedPath, {}, lockReason) +} + async function runTrial(variant: Variant, obsolete: number): Promise { const controllers = Array.from({ length: obsolete }, () => new AbortController()) const obsoletePaths = controllers.map(() => nextPreparedPath('obsolete')) @@ -69,11 +81,7 @@ async function runTrial(variant: Variant, obsolete: number): Promise { await checkout(freshPath) const freshCheckoutMs = performance.now() - started await Promise.all(obsoleteWork) - await Promise.all( - [...obsoletePaths, freshPath].map((path) => - discardPreparedWorktree(repoPath, path).catch(() => {}) - ) - ) + await Promise.all([...obsoletePaths, freshPath].map((path) => discard(path).catch(() => {}))) return { variant, obsolete, freshCheckoutMs } } @@ -118,7 +126,7 @@ describeBench('obsolete preparation cancellation latency', () => { // Warm the object store and page cache once so the first variant is not penalised. const warm = nextPreparedPath('warm') await checkout(warm) - await discardPreparedWorktree(repoPath, warm) + await discard(warm) const samples: Sample[] = [] for (const obsolete of OBSOLETE_COUNTS) { diff --git a/src/main/git/worktree-preparation-discard.ts b/src/main/git/worktree-preparation-discard.ts new file mode 100644 index 00000000000..bca815e8dc3 --- /dev/null +++ b/src/main/git/worktree-preparation-discard.ts @@ -0,0 +1,89 @@ +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { gitExecFileAsync } from './runner' +import { + gitExecOptions, + WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS, + type GitExecOptionsForWorktree, + type GitWorktreeExecOptions +} from './worktree-operation-options' +import { verifyWorktreePreparationLock } from './worktree-preparation-lock' +import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' + +export function gitCleanupOptions( + cwd: string, + options: GitWorktreeExecOptions +): GitExecOptionsForWorktree { + // Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately. + return gitExecOptions(cwd, { ...options, signal: undefined }) +} + +export async function performDiscardPreparedWorktree( + repoPath: string, + worktreePath: string, + options: GitWorktreeExecOptions, + expectedLockReason?: string +): Promise { + const cleanupGitOptions = { + ...gitCleanupOptions(repoPath, options), + timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + } + try { + if (expectedLockReason !== undefined) { + await verifyWorktreePreparationLock(worktreePath, expectedLockReason, cleanupGitOptions) + } + // Double force requires a freshly verified ownership marker. + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'remove', + '--force', + ...(expectedLockReason === undefined ? [] : ['--force']), + worktreePath + ], + cleanupGitOptions + ) + } finally { + invalidateWslLinkedWorktreeGitRouting(worktreePath) + } +} + +export async function removeFailedFinalization( + repoPath: string, + cleanupPath: string, + branch: string, + moved: boolean, + options: GitWorktreeExecOptions, + expectedLockReason?: string +): Promise { + let branchAttached = false + if (moved) { + try { + const { stdout } = await gitExecFileAsync( + ['symbolic-ref', '--short', 'HEAD'], + gitCleanupOptions(cleanupPath, options) + ) + branchAttached = stdout.trim() === branch + } catch { + // Detached or no longer readable. + } + } + const removed = await performDiscardPreparedWorktree( + repoPath, + cleanupPath, + options, + expectedLockReason + ).then( + () => true, + () => false + ) + if (!removed) { + return + } + if (branchAttached) { + await gitExecFileAsync( + ['branch', '-D', '--', branch], + gitCleanupOptions(repoPath, options) + ).catch(() => {}) + } +} diff --git a/src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts b/src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts new file mode 100644 index 00000000000..2f2e7b05590 --- /dev/null +++ b/src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts @@ -0,0 +1,207 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' +import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache } from './git-capability-state' +import * as runner from './runner' +import { prepareWorktreeCreateCheckout } from './worktree-create-preparation' +import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree-operation-options' + +const roots: string[] = [] +const unsupported = Object.assign(new Error("error: unknown option 'reason'"), { code: 129 }) + +beforeEach(() => { + clearGitCapabilityStateForTests() +}) + +afterEach(async () => { + vi.restoreAllMocks() + clearGitCapabilityStateForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function git(cwd: string, args: string[]): Promise { + return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim() +} + +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-fallback-cleanup-'))) + roots.push(root) + const repo = join(root, 'repo') + const prepared = join(root, 'prepared') + await git(root, ['init', '--quiet', repo]) + await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + await writeFile(join(repo, 'tracked.txt'), 'original\n') + await git(repo, ['add', 'tracked.txt']) + await git(repo, [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + 'commit', + '--quiet', + '-m', + 'initial' + ]) + return { repo, prepared } +} + +it.each([ + ['first', 'cancellation'], + ['cached', 'cancellation'], + ['first', 'path probe'], + ['cached', 'path probe'] +])('reclaims its registered %s fallback after %s before locking', async (fallback, failureKind) => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('fallback-cleanup') + const controller = new AbortController() + const failure = new Error(`${failureKind} after completed add`) + const run = runner.gitExecFileAsync + let registered = false + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (args.includes('--reason')) { + throw unsupported + } + const result = await run(args, options) + if (args.includes('--no-checkout')) { + registered = true + expect(existsSync(join(prepared, '.git'))).toBe(true) + } + if (registered && options?.cwd === prepared && args.includes('--git-path')) { + expect(existsSync(join(prepared, 'tracked.txt'))).toBe(false) + if (failureKind === 'cancellation') { + controller.abort(failure) + } + throw failure + } + return result + }) + await expect( + prepareWorktreeCreateCheckout(repo, prepared, 'main', reason, { + signal: controller.signal, + timeout: 180_000 + }) + ).rejects.toBe(failure) + const removals = spy.mock.calls.filter(([args]) => args.includes('remove')) + expect(removals).toHaveLength(1) + const [removeArgs, removeOptions] = removals[0]! + expect(removeArgs.filter((arg) => arg === '--force')).toHaveLength(1) + expect(removeOptions).toMatchObject({ + cwd: repo, + timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + }) + expect(removeOptions).not.toHaveProperty('signal') + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + expect(existsSync(prepared)).toBe(false) + expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).not.toContain( + prepared + ) +}) + +it.each(['first', 'cached'])( + 'preserves another marker after a successful %s fallback add', + async (fallback) => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('competing-fallback') + const run = runner.gitExecFileAsync + let lock = '' + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (args.includes('--reason')) { + throw unsupported + } + const result = await run(args, options) + if (args.includes('--no-checkout')) { + lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim() + await writeFile(lock, 'manual competing owner\n') + await writeFile(join(prepared, 'user.txt'), 'preserve this file\n') + } + return result + }) + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow( + 'lock owner changed' + ) + expect(await readFile(lock, 'utf8')).toBe('manual competing owner\n') + expect(await readFile(join(prepared, 'user.txt'), 'utf8')).toBe('preserve this file\n') + expect(spy.mock.calls.some(([args]) => args.includes('remove') || args.includes('reset'))).toBe( + false + ) + expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain( + prepared + ) + } +) + +it.each(['first', 'cached'])( + 'lets Git protect a competing lock from %s fallback cleanup after a generic probe failure', + async (fallback) => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('protected-fallback-cleanup') + const failure = new Error('lock path became unreadable') + const run = runner.gitExecFileAsync + let lock = '' + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (args.includes('--reason')) { + throw unsupported + } + if (lock && options?.cwd === prepared && args.includes('--git-path')) { + throw failure + } + const result = await run(args, options) + if (args.includes('--no-checkout')) { + lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim() + await writeFile(lock, 'manual protected owner\n') + await writeFile(join(prepared, 'user.txt'), 'preserve after cleanup attempt\n') + } + return result + }) + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toBe( + failure + ) + const removals = spy.mock.calls.filter(([args]) => args.includes('remove')) + expect(removals).toHaveLength(1) + expect(removals[0]![0].filter((arg) => arg === '--force')).toHaveLength(1) + expect(removals[0]![1]).not.toHaveProperty('signal') + expect(removals[0]![1]).toMatchObject({ timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS }) + expect(await readFile(lock, 'utf8')).toBe('manual protected owner\n') + expect(await readFile(join(prepared, 'user.txt'), 'utf8')).toBe( + 'preserve after cleanup attempt\n' + ) + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain( + prepared + ) + } +) + +it('preserves a pre-existing empty target if its cached fallback lock-path probe fails', async () => { + const { repo, prepared } = await fixture() + await mkdir(prepared) + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + const failure = new Error('pre-existing target lock path unavailable') + const run = runner.gitExecFileAsync + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (options?.cwd === prepared && args.includes('--git-path')) { + throw failure + } + return run(args, options) + }) + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', 'reason')).rejects.toBe( + failure + ) + expect(existsSync(join(prepared, '.git'))).toBe(true) + expect(spy.mock.calls.some(([args]) => args.includes('remove') || args.includes('reset'))).toBe( + false + ) + expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain(prepared) +}) diff --git a/src/main/git/worktree-preparation-lock-real-git.test.ts b/src/main/git/worktree-preparation-lock-real-git.test.ts new file mode 100644 index 00000000000..55c31fdc44d --- /dev/null +++ b/src/main/git/worktree-preparation-lock-real-git.test.ts @@ -0,0 +1,336 @@ +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' +import * as runner from './runner' +import { + discardPreparedWorktree, + finalizePreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' +import { unlockWorktreePreparation } from './worktree-preparation-lock' +import { + _resetPreparationPoolForTests, + listPreparations, + startPreparation, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from '../worktree-create-preparation-pool' + +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await _resetPreparationPoolForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function git(cwd: string, args: string[]): Promise { + return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim() +} + +async function fixture(): Promise<{ root: string; repo: string; prepared: string; final: string }> { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-preparation-lock-'))) + roots.push(root) + const repo = join(root, 'repo') + await git(root, ['init', '--quiet', repo]) + await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + await writeFile(join(repo, 'tracked.txt'), 'original\n') + await git(repo, ['add', 'tracked.txt']) + await git(repo, [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--quiet', + '-m', + 'initial' + ]) + return { root, repo, prepared: join(root, 'prepared'), final: join(root, 'final') } +} + +it('creates and consumes its marker without worktree lock or unlock inventory scans', async () => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('targeted') + const spy = vi.spyOn(runner, 'gitExecFileAsync') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`) + spy.mockClear() + await finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + expect(spy.mock.calls.filter(([args]) => args.includes('--git-path'))).toHaveLength(1) + expect(spy.mock.calls.filter(([args]) => args.includes('--git-common-dir'))).toHaveLength(1) + expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature') + expect(await git(final, ['status', '--porcelain'])).toBe('') + expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n') + await expect(readFile(lock, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(spy.mock.calls.some(([args]) => args.includes('lock') || args.includes('unlock'))).toBe( + false + ) +}) + +it('has its exact ownership marker before the atomic add returns', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('atomic-add') + const run = runner.gitExecFileAsync + let observed = false + vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('--reason')) { + const lock = ( + await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared }) + ).stdout.trim() + expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`) + await expect(readFile(join(prepared, 'tracked.txt'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + observed = true + } + return result + }) + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const version = await git(repo, ['--version']) + const minor = Number(version.match(/git version 2\.(\d+)/)?.[1]) + expect(observed).toBe(minor >= 33) +}) + +it('cleans only its newly registered marker when cancellation follows atomic add', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('atomic-cancellation') + const controller = new AbortController() + const run = runner.gitExecFileAsync + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('--no-checkout') && args.includes('--reason')) { + controller.abort() + throw new Error('canceled after atomic add') + } + return result + }) + await expect( + prepareWorktreeCreateCheckout(repo, prepared, 'main', reason, { signal: controller.signal }) + ).rejects.toThrow('canceled after atomic add') + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + await expect(readFile(join(prepared, '.git'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await git(repo, ['worktree', 'list', '--porcelain'])).not.toContain(prepared) +}) + +it('preserves an existing owned checkout when another add fails at its path', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('existing-add') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + const spy = vi.spyOn(runner, 'gitExecFileAsync') + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow() + expect(spy.mock.calls.some(([args]) => args.includes('remove'))).toBe(false) + expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`) + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n') +}) + +it('resolves a relative gitfile and retains a competing unlock marker', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('relative') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const adminDir = await git(prepared, ['rev-parse', '--git-dir']) + await writeFile(join(prepared, '.git'), `gitdir: ${relative(prepared, adminDir)}\n`) + const lock = join(adminDir, 'locked') + await writeFile(lock, 'manual user lock\n') + await expect(unlockWorktreePreparation(prepared, reason, {})).rejects.toThrow( + 'lock owner changed' + ) + expect(await readFile(lock, 'utf8')).toBe('manual user lock\n') + expect(await git(prepared, ['rev-parse', '--verify', 'HEAD'])).toBe( + await git(repo, ['rev-parse', 'HEAD']) + ) +}) + +it('preserves a competing marker and registration through preparation failure and pool reset', async () => { + const { root, repo } = await fixture() + let prepared = '' + let lock = '' + const run = runner.gitExecFileAsync + vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('reset') && options?.cwd) { + prepared = options.cwd + lock = (await run(['rev-parse', '--git-path', 'locked'], options)).stdout.trim() + await writeFile(lock, 'manual competing preparation\n') + } + return result + }) + await expect( + startPreparation({ + repoPath: repo, + workspaceRoot: root, + baseBranch: 'main', + canonicalBase: 'refs/heads/main', + options: {} + }) + ).rejects.toThrow('lock owner changed') + await _resetPreparationPoolForTests() + expect(await readFile(lock, 'utf8')).toBe('manual competing preparation\n') + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n') + expect(await git(repo, ['worktree', 'list', '--porcelain'])).toContain( + 'manual competing preparation' + ) +}) + +it('claims the marker before materialization and preserves a competing owner after add', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('before-materialization') + const run = runner.gitExecFileAsync + let lock = '' + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('--no-checkout')) { + lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim() + await writeFile(lock, 'manual before materialization\n') + await writeFile(join(prepared, 'tracked.txt'), 'user checkout content\n') + } + return result + }) + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow( + 'lock owner changed' + ) + expect(spy.mock.calls.some(([args]) => args.includes('reset') || args.includes('remove'))).toBe( + false + ) + expect(await readFile(lock, 'utf8')).toBe('manual before materialization\n') + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('user checkout content\n') +}) + +it.each(['checkout', 'push.autoSetupRemote'])( + 'preserves the finalized checkout when its marker is replaced during %s', + async (command) => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('replacement') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + const run = runner.gitExecFileAsync + vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (args.includes(command)) { + await writeFile(lock, 'manual finalized lock\n') + } + return run(args, options) + }) + await expect( + finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + ).rejects.toThrow('lock owner changed') + expect(await readFile(lock, 'utf8')).toBe('manual finalized lock\n') + expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n') + expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature') + } +) + +it('leaves a replacement owner untouched before any reset, move, or branch attachment', async () => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('replaced-before-finalize') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual replacement\n') + await writeFile(join(prepared, 'tracked.txt'), 'user edits\n') + const spy = vi.spyOn(runner, 'gitExecFileAsync') + await expect( + finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + ).rejects.toThrow('lock owner changed') + expect( + spy.mock.calls.some( + ([args]) => args.includes('reset') || args.includes('move') || args.includes('checkout') + ) + ).toBe(false) + expect(await readFile(lock, 'utf8')).toBe('manual replacement\n') + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('user edits\n') + expect(await git(repo, ['branch', '--list', 'feature'])).toBe('') +}) + +it('checks replacement ownership after move before attaching a branch', async () => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('replaced-after-move') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + const run = runner.gitExecFileAsync + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('move')) { + await writeFile(lock, 'manual moved lock\n') + } + return result + }) + await expect( + finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + ).rejects.toThrow('lock owner changed') + expect( + spy.mock.calls.some(([args]) => args.includes('checkout') || args.includes('remove')) + ).toBe(false) + expect(await readFile(lock, 'utf8')).toBe('manual moved lock\n') + expect(await git(final, ['symbolic-ref', '--quiet', 'HEAD']).catch(() => 'detached')).toBe( + 'detached' + ) + expect(await git(repo, ['branch', '--list', 'feature'])).toBe('') +}) + +it.each(['replacement', 'missing'])( + 'preserves checkout and branch if failure cleanup finds a %s marker', + async (marker) => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('failure-cleanup') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + const run = runner.gitExecFileAsync + vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('checkout')) { + await (marker === 'replacement' ? writeFile(lock, 'manual failed lock\n') : rm(lock)) + throw new Error('injected checkout failure') + } + return result + }) + await expect( + finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + ).rejects.toThrow('injected checkout failure') + expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n') + expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature') + expect(await git(repo, ['branch', '--list', 'feature'])).toContain('feature') + if (marker === 'replacement') { + expect(await readFile(lock, 'utf8')).toBe('manual failed lock\n') + } + } +) + +it('refuses a force discard after the marker was replaced', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('discard-replacement') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual discard lock\n') + await expect(discardPreparedWorktree(repo, prepared, {}, reason)).rejects.toThrow( + 'lock owner changed' + ) + expect(await readFile(lock, 'utf8')).toBe('manual discard lock\n') + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n') +}) + +it.each(['expiry', 'pool reset'])('preserves a replacement marker during %s', async (kind) => { + const { root, repo } = await fixture() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + await startPreparation({ + repoPath: repo, + workspaceRoot: root, + baseBranch: 'main', + canonicalBase: 'refs/heads/main', + options: {} + }) + const entry = listPreparations()[0] + const lock = await git(entry.preparedPath, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual expired lock\n') + if (kind === 'expiry') { + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_PREPARATION_TTL_MS) + } + await _resetPreparationPoolForTests() + expect(await readFile(lock, 'utf8')).toBe('manual expired lock\n') + expect(await readFile(join(entry.preparedPath, 'tracked.txt'), 'utf8')).toBe('original\n') + } finally { + vi.useRealTimers() + } +}) diff --git a/src/main/git/worktree-preparation-lock.test.ts b/src/main/git/worktree-preparation-lock.test.ts new file mode 100644 index 00000000000..8120514a695 --- /dev/null +++ b/src/main/git/worktree-preparation-lock.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { toHostFilesystemPath } from '../host-tree-removal' +import type * as FilePromises from 'node:fs/promises' + +const mocks = vi.hoisted(() => ({ + git: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn() +})) +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: mocks.readFile, + writeFile: mocks.writeFile, + unlink: mocks.unlink +})) + +import { + lockWorktreePreparation, + resolveWorktreePreparationLockPath, + unlockWorktreePreparation, + unlockWorktreePreparationAtPath +} from './worktree-preparation-lock' + +const lockReason = 'orca-create-preparation:v1:123:exact-session' +const commonDir = join(tmpdir(), 'repo', '.git') +const gitLockPath = join(commonDir, 'worktrees', 'prepared', 'locked') +const lockPath = toHostFilesystemPath(gitLockPath) + +beforeEach(() => { + mocks.git.mockReset().mockImplementation(async (args: string[]) => ({ + stdout: `${args.includes('--git-path') ? gitLockPath : commonDir}\n` + })) + mocks.readFile.mockReset().mockResolvedValue(`${lockReason}\n`) + mocks.writeFile.mockReset().mockResolvedValue(undefined) + mocks.unlink.mockReset().mockResolvedValue(undefined) +}) + +describe('targeted preparation lock ownership', () => { + it('creates Git’s reason marker exclusively without enumerating worktrees', async () => { + const options = { wslDistro: 'Ubuntu', timeout: 8000, admissionTier: 'interactive' as const } + await lockWorktreePreparation('/prepared', lockReason, options) + expect(mocks.git).toHaveBeenCalledTimes(2) + expect(mocks.git).toHaveBeenCalledWith(['rev-parse', '--git-path', 'locked'], { + cwd: '/prepared', + ...options + }) + expect(mocks.git).toHaveBeenCalledWith(['rev-parse', '--git-common-dir'], { + cwd: '/prepared', + ...options + }) + expect(mocks.writeFile).toHaveBeenCalledExactlyOnceWith(lockPath, `${lockReason}\n`, { + flag: 'wx' + }) + }) + + it('preserves an existing lock if exclusive creation fails', async () => { + const error = Object.assign(new Error('lock exists'), { code: 'EEXIST' }) + mocks.writeFile.mockRejectedValueOnce(error) + await expect(lockWorktreePreparation('/prepared', lockReason, {})).rejects.toThrow( + 'lock owner changed' + ) + expect(mocks.unlink).not.toHaveBeenCalled() + }) + + it('unlinks only the exact reason minted for this checkout', async () => { + await unlockWorktreePreparation('/final', lockReason, {}) + expect(mocks.readFile).toHaveBeenCalledExactlyOnceWith(lockPath, 'utf8') + expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith(lockPath) + }) + + it('reuses a verified administrative path and reads the owner again before unlinking', async () => { + await unlockWorktreePreparationAtPath(lockPath, lockReason) + expect(mocks.git).not.toHaveBeenCalled() + expect(mocks.readFile).toHaveBeenCalledExactlyOnceWith(lockPath, 'utf8') + expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith(lockPath) + }) + + it.each([ + 'user lock\n', + 'orca-create-preparation:v1:123:another-session\n', + lockReason, + `${lockReason}\n\n` + ])('preserves a replacement owner: %s', async (reason) => { + mocks.readFile.mockResolvedValueOnce(reason) + await expect(unlockWorktreePreparation('/final', lockReason, {})).rejects.toThrow( + 'lock owner changed' + ) + expect(mocks.unlink).not.toHaveBeenCalled() + }) + + it.each(['ENOENT', 'EACCES'])( + 'preserves the checkout when marker ownership cannot be read: %s', + async (code) => { + mocks.readFile.mockRejectedValueOnce(Object.assign(new Error('marker unavailable'), { code })) + await expect(unlockWorktreePreparation('/final', lockReason, {})).rejects.toThrow( + 'lock owner changed' + ) + expect(mocks.unlink).not.toHaveBeenCalled() + } + ) + + it('honors cancellation after the path probe before writing a lock', async () => { + const controller = new AbortController() + mocks.git.mockImplementationOnce(async () => { + controller.abort() + return { stdout: `${gitLockPath}\n` } + }) + await expect( + lockWorktreePreparation('/prepared', lockReason, { signal: controller.signal }) + ).rejects.toThrow() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) + + it('honors cancellation during the ownership read before unlinking', async () => { + const controller = new AbortController() + const cancellation = new Error('unlock canceled') + mocks.readFile.mockImplementationOnce(async () => { + controller.abort(cancellation) + return `${lockReason}\n` + }) + await expect( + unlockWorktreePreparationAtPath(lockPath, lockReason, controller.signal) + ).rejects.toBe(cancellation) + expect(mocks.unlink).not.toHaveBeenCalled() + }) +}) + +describe('Git preparation lock path resolution', () => { + it.each([ + { + path: '/prepared', + lock: '/repo/.git/worktrees/prepared/locked\n', + common: '/repo/.git\n', + expected: '/repo/.git/worktrees/prepared/locked', + options: { platform: 'linux' as const } + }, + { + path: '/workspace/prepared', + lock: '../../repo/.git/worktrees/prepared/locked\n', + common: '../../repo/.git\n', + expected: '/repo/.git/worktrees/prepared/locked', + options: { platform: 'linux' as const } + }, + { + path: String.raw`C:\workspace\prepared`, + lock: 'C:/repo/.git/worktrees/prepared/locked\n', + common: 'C:/repo/.git\n', + expected: 'C:/repo/.git/worktrees/prepared/locked', + options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } + }, + { + path: String.raw`\\wsl.localhost\Ubuntu\home\workspace\prepared`, + lock: '/home/repo/.git/worktrees/prepared/locked\n', + common: '/home/repo/.git\n', + expected: String.raw`\\wsl.localhost\Ubuntu\home\repo\.git\worktrees\prepared\locked`, + options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } + }, + { + path: String.raw`C:\workspace\prepared`, + lock: '/mnt/c/repo/.git/worktrees/prepared/locked\n', + common: '/mnt/c/repo/.git\n', + expected: String.raw`C:\repo\.git\worktrees\prepared\locked`, + options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } + }, + { + path: '/workspace/new\nline/prepared', + lock: '/repo/new\nline/.git/worktrees/prepared/locked\n', + common: '/repo/new\nline/.git\n', + expected: '/repo/new\nline/.git/worktrees/prepared/locked', + options: { platform: 'linux' as const } + } + ])( + 'resolves $path in the filesystem namespace that owns Git', + ({ path, lock, common, expected, options }) => { + expect(resolveWorktreePreparationLockPath(path, lock, common, options)).toBe(expected) + } + ) + + it.each([ + { lock: '/repo/.git/locked\n', common: '/repo/.git\n' }, + { lock: '/other/.git/worktrees/prepared/locked\n', common: '/repo/.git\n' }, + { lock: '/repo/.git/worktrees/prepared/nested/locked\n', common: '/repo/.git\n' }, + { lock: '/repo/.git/worktrees/prepared/locked\n', common: '' } + ])('rejects a path without one linked administration entry: $lock', ({ lock, common }) => { + expect(() => resolveWorktreePreparationLockPath('/prepared', lock, common)).toThrow( + 'linked worktree lock path' + ) + }) +}) diff --git a/src/main/git/worktree-preparation-lock.ts b/src/main/git/worktree-preparation-lock.ts new file mode 100644 index 00000000000..ba60280b90e --- /dev/null +++ b/src/main/git/worktree-preparation-lock.ts @@ -0,0 +1,130 @@ +import { readFile, unlink, writeFile } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { resolveGitMetadataPath, type GitMetadataPathOptions } from '../../shared/git-metadata-path' +import { toHostFilesystemPath } from '../host-tree-removal' +import { gitExecFileAsync } from './runner' +import { + getErrorCode, + gitExecOptions, + type GitWorktreeExecOptions +} from './worktree-operation-options' + +export class WorktreePreparationLockOwnershipError extends Error { + constructor(cause?: unknown) { + super('The prepared worktree lock owner changed', { cause }) + } +} + +export function resolveWorktreePreparationLockPath( + worktreePath: string, + rawLockPath: string, + rawCommonDir: string, + options: GitMetadataPathOptions = {} +): string { + const lockPath = resolveGitMetadataPath(worktreePath, rawLockPath, options) + const commonDir = resolveGitMetadataPath(worktreePath, rawCommonDir, options) + if (!lockPath || !commonDir) { + throw new Error('Git did not return a linked worktree lock path') + } + const paths = isWindowsAbsolutePathLike(lockPath) ? win32 : posix + const segments = paths.relative(commonDir, lockPath).split(paths.sep) + if ( + segments.length !== 3 || + segments[0] !== 'worktrees' || + !segments[1] || + segments[2] !== 'locked' + ) { + throw new Error('Git did not return a linked worktree lock path') + } + return lockPath +} + +async function readPreparationLockPath( + worktreePath: string, + options: GitWorktreeExecOptions +): Promise { + const results = await Promise.allSettled([ + gitExecFileAsync(['rev-parse', '--git-path', 'locked'], gitExecOptions(worktreePath, options)), + gitExecFileAsync(['rev-parse', '--git-common-dir'], gitExecOptions(worktreePath, options)) + ]) + const [lock, common] = results + if (lock.status === 'rejected') { + throw lock.reason + } + if (common.status === 'rejected') { + throw common.reason + } + return toHostFilesystemPath( + resolveWorktreePreparationLockPath( + worktreePath, + lock.value.stdout, + common.value.stdout, + options + ) + ) +} + +export async function lockWorktreePreparation( + worktreePath: string, + lockReason: string, + options: GitWorktreeExecOptions +): Promise { + const lockPath = await readPreparationLockPath(worktreePath, options) + options.signal?.throwIfAborted() + // Git's own lock marker is a reason plus newline; exclusive creation preserves another owner. + try { + await writeFile(lockPath, `${lockReason}\n`, { flag: 'wx' }) + } catch (error) { + if (getErrorCode(error) === 'EEXIST') { + throw new WorktreePreparationLockOwnershipError() + } + throw error + } + return lockPath +} + +export async function unlockWorktreePreparation( + worktreePath: string, + expectedLockReason: string, + options: GitWorktreeExecOptions +): Promise { + const lockPath = await readPreparationLockPath(worktreePath, options) + await unlockWorktreePreparationAtPath(lockPath, expectedLockReason, options.signal) +} + +/** A move preserves the linked administration directory already verified by finalization. */ +export async function unlockWorktreePreparationAtPath( + lockPath: string, + expectedLockReason: string, + signal?: AbortSignal +): Promise { + await verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, signal) + await unlink(lockPath).catch((error: unknown) => { + throw new WorktreePreparationLockOwnershipError(error) + }) +} + +export async function verifyWorktreePreparationLock( + worktreePath: string, + expectedLockReason: string, + options: GitWorktreeExecOptions +): Promise { + const lockPath = await readPreparationLockPath(worktreePath, options) + await verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, options.signal) + return lockPath +} + +export async function verifyWorktreePreparationLockAtPath( + lockPath: string, + expectedLockReason: string, + signal?: AbortSignal +): Promise { + const lockReason = await readFile(lockPath, 'utf8').catch((error: unknown) => { + throw new WorktreePreparationLockOwnershipError(error) + }) + if (lockReason !== `${expectedLockReason}\n`) { + throw new WorktreePreparationLockOwnershipError() + } + signal?.throwIfAborted() +} diff --git a/src/main/git/worktree-preparation-tip-refresh-real-git.test.ts b/src/main/git/worktree-preparation-tip-refresh-real-git.test.ts new file mode 100644 index 00000000000..a1bc9082f57 --- /dev/null +++ b/src/main/git/worktree-preparation-tip-refresh-real-git.test.ts @@ -0,0 +1,156 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' +import * as runner from './runner' +import { + finalizePreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' +import { refreshPreparedWorktreeTip } from './worktree-preparation-tip-refresh' +import { + _resetPreparationPoolForTests, + listPreparations, + releasePreparationClaim, + startPreparation, + takePreparation +} from '../worktree-create-preparation-pool' + +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await _resetPreparationPoolForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function git(cwd: string, args: string[]): Promise { + return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim() +} + +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-fetched-preparation-'))) + roots.push(root) + const repo = join(root, 'repo') + const prepared = join(root, 'prepared') + const final = join(root, 'final') + await git(root, ['init', '--quiet', repo]) + await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + await git(repo, ['config', 'user.name', 'Test']) + await git(repo, ['config', 'user.email', 'test@example.com']) + await writeFile(join(repo, 'version.txt'), 'original\n') + await git(repo, ['add', 'version.txt']) + await git(repo, ['commit', '--quiet', '-m', 'initial']) + const hooks = join(root, 'hooks') + await mkdir(hooks) + await writeFile(join(hooks, 'post-checkout'), '#!/bin/sh\necho checkout >> checkout-hook.txt\n', { + mode: 0o755 + }) + await git(repo, ['config', 'core.hooksPath', hooks]) + const base = 'refs/remotes/origin/main' + await git(repo, ['update-ref', base, 'HEAD']) + const reason = createWorktreePreparationLockReason('fetched-tip') + await prepareWorktreeCreateCheckout(repo, prepared, base, reason) + return { root, repo, prepared, final, base, reason } +} + +async function advance(repo: string, base: string, text: string): Promise { + await writeFile(join(repo, 'version.txt'), text) + await git(repo, ['commit', '--quiet', '-am', text.trim()]) + const head = await git(repo, ['rev-parse', 'HEAD']) + await git(repo, ['update-ref', base, head]) + return head +} + +it('moves changed tip work into prefetch while submit still runs exactly one checkout hook', async () => { + const { repo, prepared, final, base, reason } = await fixture() + const target = await advance(repo, base, 'fetched\n') + const spy = vi.spyOn(runner, 'gitExecFileAsync') + await refreshPreparedWorktreeTip(repo, prepared, base, reason) + expect(await readFile(join(prepared, 'version.txt'), 'utf8')).toBe('fetched\n') + expect(await git(prepared, ['rev-parse', 'HEAD'])).toBe(target) + expect(existsSync(join(prepared, 'checkout-hook.txt'))).toBe(false) + expect(spy.mock.calls.filter(([args]) => args.includes('reset'))).toHaveLength(1) + spy.mockClear() + await refreshPreparedWorktreeTip(repo, prepared, base, reason) + await finalizePreparedWorktree(repo, prepared, final, 'feature', base, false, {}, reason) + expect(spy.mock.calls.filter(([args]) => args.includes('reset'))).toHaveLength(0) + expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n') + expect(await git(final, ['rev-parse', 'HEAD'])).toBe(target) + expect(await git(final, ['status', '--porcelain', '--untracked-files=no'])).toBe('') +}) + +it('revalidates a newer fetched tip that arrives after the background refresh', async () => { + const { repo, prepared, final, base, reason } = await fixture() + await advance(repo, base, 'first fetch\n') + await refreshPreparedWorktreeTip(repo, prepared, base, reason) + const newest = await advance(repo, base, 'second fetch\n') + await finalizePreparedWorktree(repo, prepared, final, 'feature', base, false, {}, reason) + expect(await git(final, ['rev-parse', 'HEAD'])).toBe(newest) + expect(await readFile(join(final, 'version.txt'), 'utf8')).toBe('second fetch\n') + expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n') +}) + +it('preserves a competing lock and files when ownership changes before refresh', async () => { + const { repo, prepared, base, reason } = await fixture() + await advance(repo, base, 'new fetch\n') + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual owner\n') + await expect(refreshPreparedWorktreeTip(repo, prepared, base, reason)).rejects.toThrow( + 'lock owner changed' + ) + expect(await readFile(lock, 'utf8')).toBe('manual owner\n') + expect(await readFile(join(prepared, 'version.txt'), 'utf8')).toBe('original\n') + expect(await git(repo, ['worktree', 'list', '--porcelain'])).toContain('locked manual owner') +}) + +it('makes a racing claim wait for one fetched-tip reset on an already ready checkout', async () => { + const { root, repo, final, base } = await fixture() + const args = { + repoPath: repo, + workspaceRoot: root, + baseBranch: base, + canonicalBase: base, + options: {} + } + await startPreparation(args) + const entry = listPreparations()[0]! + let settle!: () => void + const beforeMaterialization = new Promise((resolve) => { + settle = resolve + }) + const spy = vi.spyOn(runner, 'gitExecFileAsync') + const refreshing = startPreparation({ ...args, beforeMaterialization }) + const claim = takePreparation(entry) + let finalized = false + const create = entry.ready.then(async () => { + await finalizePreparedWorktree( + repo, + entry.preparedPath, + final, + 'feature', + base, + false, + {}, + entry.lockReason + ) + finalized = true + }) + try { + await Promise.resolve() + expect(finalized).toBe(false) + expect(spy.mock.calls.filter(([argv]) => argv.includes('reset'))).toHaveLength(0) + const target = await advance(repo, base, 'fetched ready tip\n') + expect(finalized).toBe(false) + settle() + await Promise.all([refreshing, create]) + expect(spy.mock.calls.filter(([argv]) => argv.includes('reset'))).toHaveLength(1) + expect(await git(final, ['rev-parse', 'HEAD'])).toBe(target) + expect(await readFile(join(final, 'version.txt'), 'utf8')).toBe('fetched ready tip\n') + expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n') + } finally { + settle() + releasePreparationClaim(claim) + } +}) diff --git a/src/main/git/worktree-preparation-tip-refresh.test.ts b/src/main/git/worktree-preparation-tip-refresh.test.ts new file mode 100644 index 00000000000..0e377e1bc77 --- /dev/null +++ b/src/main/git/worktree-preparation-tip-refresh.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + git: vi.fn(), + verify: vi.fn(), + verifyAt: vi.fn(), + mutation: vi.fn(), + invalidation: vi.fn() +})) +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git })) +vi.mock('./worktree', () => ({ notifyPreparedWorktreeMutation: mocks.mutation })) +vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: mocks.invalidation })) +vi.mock('./local-repo-ref-maintenance', () => ({ + withRepoRefMaintenancePaused: (_reason: string, run: () => Promise) => run() +})) +vi.mock('./worktree-preparation-lock', () => ({ + verifyWorktreePreparationLock: mocks.verify, + verifyWorktreePreparationLockAtPath: mocks.verifyAt +})) + +import { refreshPreparedWorktreeTip } from './worktree-preparation-tip-refresh' + +const OLD = 'a'.repeat(40) +const NEW = 'b'.repeat(40) +const BASE = 'refs/remotes/origin/main' + +beforeEach(() => { + mocks.git.mockReset().mockImplementation(async (args: string[], options: { cwd?: string }) => ({ + stdout: args[0] === 'rev-parse' && options.cwd === '/repo' ? `${NEW}\n` : `${OLD}\n` + })) + mocks.verify.mockReset().mockResolvedValue('/repo/.git/worktrees/prepared/locked') + mocks.verifyAt.mockReset().mockImplementation(async (_lock, _reason, signal?: AbortSignal) => { + signal?.throwIfAborted() + }) + mocks.mutation.mockReset() + mocks.invalidation.mockReset().mockImplementation((run: () => Promise) => run()) +}) + +describe('prepared checkout fetched tip materialization', () => { + it('does no index or file mutation when the fetched tip is unchanged', async () => { + mocks.git.mockResolvedValue({ stdout: `${OLD}\n` }) + await refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner') + expect(mocks.git.mock.calls.map(([args]) => args)).toEqual([ + ['rev-parse', '--verify', `${BASE}^{commit}`], + ['rev-parse', '--verify', 'HEAD'] + ]) + expect(mocks.invalidation).not.toHaveBeenCalled() + expect(mocks.mutation).not.toHaveBeenCalled() + }) + + it('resets only to the fetched commit on the owning WSL host without checkout hooks', async () => { + const signal = new AbortController().signal + await refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner', { + wslDistro: 'Ubuntu', + admissionTier: 'status', + signal + }) + expect(mocks.git).toHaveBeenLastCalledWith( + ['reset', '--hard', NEW], + expect.objectContaining({ + cwd: '/prepared', + wslDistro: 'Ubuntu', + admissionTier: 'status', + signal + }) + ) + expect(mocks.git.mock.calls.some(([args]) => args.includes('checkout'))).toBe(false) + expect(mocks.verifyAt).toHaveBeenCalledTimes(2) + expect(mocks.mutation).toHaveBeenCalledOnce() + }) + + it('stops before any probe when the stored lock belongs to another owner', async () => { + mocks.verify.mockRejectedValueOnce(new Error('ownership lost')) + await expect(refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner')).rejects.toThrow( + 'ownership lost' + ) + expect(mocks.git).not.toHaveBeenCalled() + }) + + it('rechecks ownership after both reads before touching files', async () => { + mocks.verifyAt.mockRejectedValueOnce(new Error('ownership changed during probes')) + await expect(refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner')).rejects.toThrow( + 'ownership changed' + ) + expect(mocks.git).toHaveBeenCalledTimes(2) + expect(mocks.invalidation).not.toHaveBeenCalled() + }) + + it('settles both probes before reporting a failed ref read', async () => { + let release!: () => void + mocks.git + .mockRejectedValueOnce(new Error('base unavailable')) + .mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve({ stdout: OLD }))) + ) + let settled = false + const refresh = refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner').finally(() => { + settled = true + }) + const assertion = expect(refresh).rejects.toThrow('base unavailable') + await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(2)) + expect(settled).toBe(false) + release() + await assertion + expect(mocks.invalidation).not.toHaveBeenCalled() + }) + + it('honors cancellation between probes and materialization', async () => { + const controller = new AbortController() + mocks.git.mockImplementation(async (_args: string[], options: { cwd?: string }) => { + controller.abort() + return { stdout: options.cwd === '/repo' ? NEW : OLD } + }) + await expect( + refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner', { signal: controller.signal }) + ).rejects.toThrow() + expect(mocks.git).toHaveBeenCalledTimes(2) + expect(mocks.invalidation).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/worktree-preparation-tip-refresh.ts b/src/main/git/worktree-preparation-tip-refresh.ts new file mode 100644 index 00000000000..fd8bde49aeb --- /dev/null +++ b/src/main/git/worktree-preparation-tip-refresh.ts @@ -0,0 +1,60 @@ +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' +import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './status' +import { notifyPreparedWorktreeMutation } from './worktree' +import { + gitExecOptions, + resolveWorktreeAddTimeoutMs, + type GitWorktreeExecOptions +} from './worktree-operation-options' +import { + verifyWorktreePreparationLock, + verifyWorktreePreparationLockAtPath +} from './worktree-preparation-lock' + +export async function refreshPreparedWorktreeTip( + repoPath: string, + preparedPath: string, + canonicalBase: string, + lockReason: string, + options: GitWorktreeExecOptions = {} +): Promise { + const refreshOptions = { ...options, timeout: options.timeout ?? resolveWorktreeAddTimeoutMs() } + await withRepoRefMaintenancePaused('worktree-prepare', async () => { + const lockPath = await verifyWorktreePreparationLock(preparedPath, lockReason, refreshOptions) + const [target, prepared] = await Promise.allSettled([ + gitExecFileAsync( + ['rev-parse', '--verify', `${canonicalBase}^{commit}`], + gitExecOptions(repoPath, refreshOptions) + ), + gitExecFileAsync( + ['rev-parse', '--verify', 'HEAD'], + gitExecOptions(preparedPath, refreshOptions) + ) + ]) + if (target.status === 'rejected') { + throw target.reason + } + if (prepared.status === 'rejected') { + throw prepared.reason + } + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, refreshOptions.signal) + const targetHead = target.value.stdout.trim() + if (prepared.value.stdout.trim() === targetHead) { + return + } + try { + // Reset preserves detached HEAD and does not run post-checkout hooks before submit. + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync( + [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], + gitExecOptions(preparedPath, refreshOptions) + ) + ) + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, refreshOptions.signal) + } finally { + notifyPreparedWorktreeMutation(repoPath) + } + }) +} diff --git a/src/main/ipc/created-worktree-reconciliation.test.ts b/src/main/ipc/created-worktree-reconciliation.test.ts index b62088ef105..8d72742920e 100644 --- a/src/main/ipc/created-worktree-reconciliation.test.ts +++ b/src/main/ipc/created-worktree-reconciliation.test.ts @@ -109,25 +109,26 @@ describe('resolveCreatedWorktree', () => { await expect( resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') ).resolves.toEqual({ created: CREATED, worktrees: [MAIN, CREATED], listingComplete: true }) - expect(describeCreatedWorktree).not.toHaveBeenCalled() + expect(describeCreatedWorktree).toHaveBeenCalledOnce() }) - it('completes the create from the direct read when the listing fails', async () => { - vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git timed out.')) + it('verifies only the new checkout without listing or probing every existing worktree', async () => { vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED) await expect( resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') ).resolves.toEqual({ created: CREATED, worktrees: [], listingComplete: false }) + expect(describeCreatedWorktree).toHaveBeenCalledOnce() + expect(listWorktreesSharedStrict).not.toHaveBeenCalled() }) - it('completes the create from the direct read when the listing omits the row', async () => { - vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN]) - vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED) + it('uses the whole listing when the direct checkout witness cannot be read', async () => { + vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN, CREATED]) + vi.mocked(describeCreatedWorktree).mockRejectedValue(new Error('rev-parse exploded')) await expect( resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') - ).resolves.toMatchObject({ created: CREATED, listingComplete: false }) + ).resolves.toEqual({ created: CREATED, worktrees: [MAIN, CREATED], listingComplete: true }) }) it("surfaces the listing's own failure rather than an opaque message", async () => { @@ -186,35 +187,50 @@ describe('resolveCreatedWorktree', () => { }) }) - it('charges the recovery what the listing left of the budget, not a fresh one', async () => { - vi.mocked(listWorktreesSharedStrict).mockImplementation(async () => { + it('charges the listing fallback what the direct read left of the budget', async () => { + vi.mocked(describeCreatedWorktree).mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 60)) - throw new Error('git worktree list timed out.') + return undefined }) - vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED) + vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED]) await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') - const options = vi.mocked(describeCreatedWorktree).mock.lastCall?.[3] + const options = vi.mocked(listWorktreesSharedStrict).mock.lastCall?.[1] expect(options?.timeout).toBeGreaterThanOrEqual(5_000) expect(options?.timeout).toBeLessThan(30_000) }) it("keeps the caller's own deadline instead of the shared budget", async () => { - vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git worktree list failed.')) - vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED) + vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED]) await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { timeout: 1_234 }) expect(vi.mocked(describeCreatedWorktree).mock.lastCall?.[3]).toMatchObject({ timeout: 1_234 }) + expect(vi.mocked(listWorktreesSharedStrict).mock.lastCall?.[1]).toMatchObject({ + timeout: 1_234 + }) }) - it('forwards exec options only when the caller supplied them', async () => { + it('forwards execution-host options to both checkout verification and the fallback', async () => { vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED]) await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') - expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith('/repo') + expect(describeCreatedWorktree).toHaveBeenLastCalledWith( + '/repo', + '/workspaces/feature', + 'feature' + ) await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { wslDistro: 'Ubuntu' }) - expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith('/repo', { wslDistro: 'Ubuntu' }) + expect(describeCreatedWorktree).toHaveBeenLastCalledWith( + '/repo', + '/workspaces/feature', + 'feature', + { wslDistro: 'Ubuntu' } + ) + expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith( + '/repo', + expect.objectContaining({ wslDistro: 'Ubuntu' }) + ) }) }) diff --git a/src/main/ipc/created-worktree-reconciliation.ts b/src/main/ipc/created-worktree-reconciliation.ts index ac5b79954c2..3369ae6f646 100644 --- a/src/main/ipc/created-worktree-reconciliation.ts +++ b/src/main/ipc/created-worktree-reconciliation.ts @@ -23,7 +23,7 @@ export function findCreatedWorktree export type CreatedWorktreeResolution = { created: GitWorktreeInfo - /** Rows `git worktree list` returned; empty when only the direct read found the worktree. */ + /** Rows `git worktree list` returned; empty when the direct read found the worktree. */ worktrees: readonly GitWorktreeInfo[] /** Whether `worktrees` is the repo's whole listing, and so usable as its authorized-root set. */ listingComplete: boolean @@ -36,16 +36,10 @@ export function createdWorktreeNotFoundError(worktreePath: string, branchName: s ) } -/** - * Find the row for a worktree `git worktree add` just created, preferring the repo listing and - * falling back to asking Git about the worktree itself. - * - * Why the fallback: the listing was the only witness the old code had, so any Git-level listing - * failure failed a create whose worktree and branch were already on disk, orphaning both (#16520). - */ -/** A listing that burned the whole budget still leaves the direct read a chance to answer. */ -const MIN_CREATED_WORKTREE_RECOVERY_MS = 5_000 +/** A failed direct read still leaves the listing a chance to verify the create. */ +const MIN_CREATED_WORKTREE_LIST_FALLBACK_MS = 5_000 +/** Verify the new checkout directly; listing every existing worktree is only a fallback. */ export async function resolveCreatedWorktree( repoPath: string, worktreePath: string, @@ -53,11 +47,28 @@ export async function resolveCreatedWorktree( options?: GitWorktreeExecOptions ): Promise { const startedAt = Date.now() + let directReadError: Error | undefined + try { + const created = options + ? await describeCreatedWorktree(repoPath, worktreePath, branchName, options) + : await describeCreatedWorktree(repoPath, worktreePath, branchName) + if (created) { + return { created, worktrees: [], listingComplete: false } + } + } catch (err) { + directReadError = err instanceof Error ? err : new Error(String(err)) + } + + const remainingMs = Math.max( + (options?.timeout ?? WORKTREE_LIST_TIMEOUT_MS) - (Date.now() - startedAt), + MIN_CREATED_WORKTREE_LIST_FALLBACK_MS + ) let listingError: Error | undefined try { - const worktrees = options - ? await listWorktreesSharedStrict(repoPath, options) - : await listWorktreesSharedStrict(repoPath) + const worktrees = await listWorktreesSharedStrict(repoPath, { + ...options, + timeout: options?.timeout ?? remainingMs + }) const created = findCreatedWorktree(worktrees, worktreePath, branchName) if (created) { return { created, worktrees, listingComplete: true } @@ -65,39 +76,18 @@ export async function resolveCreatedWorktree( } catch (err) { listingError = err instanceof Error ? err : new Error(String(err)) } - - try { - // One budget for verifying the create, not one per attempt: a hung Git already spent the - // listing's deadline, and charging the recovery a fresh one doubles the wait before the error. - const remainingMs = Math.max( - WORKTREE_LIST_TIMEOUT_MS - (Date.now() - startedAt), - MIN_CREATED_WORKTREE_RECOVERY_MS - ) - const described = await describeCreatedWorktree(repoPath, worktreePath, branchName, { - ...options, - timeout: options?.timeout ?? remainingMs - }) - if (described) { - return { created: described, worktrees: [], listingComplete: false } - } - } catch (err) { - if (listingError) { - // The listing's failure stays the thrown one, but the recovery's reason -- often - // `repo common dir unverifiable: ...` -- would otherwise vanish from the record entirely. + if (listingError) { + if (directReadError) { console.warn('[worktrees:create] created-worktree recovery also failed', { - err, + err: directReadError, worktreePath }) - throw listingError } - // The listing simply omitted the row, so the direct read holds the only actionable failure. - const notFound = createdWorktreeNotFoundError(worktreePath, branchName) - throw new Error(`${notFound.message}: ${err instanceof Error ? err.message : String(err)}`, { - cause: err - }) - } - if (listingError) { throw listingError } - throw createdWorktreeNotFoundError(worktreePath, branchName) + const notFound = createdWorktreeNotFoundError(worktreePath, branchName) + if (directReadError) { + throw new Error(`${notFound.message}: ${directReadError.message}`, { cause: directReadError }) + } + throw notFound } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 256cb87fe9f..11b897b81db 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -371,7 +371,7 @@ export function recordWorkspaceLineageForCreatedWorktree( async function spawnLocalStartupAndSetupTerminals(args: { runtime: OrcaRuntimeService | undefined - worktree: Pick + worktree: Worktree startup: CreateWorktreeArgs['startup'] setup: CreateWorktreeResult['setup'] defaultTabs: CreateWorktreeResult['defaultTabs'] @@ -410,18 +410,22 @@ async function spawnLocalStartupAndSetupTerminals(args: { try { // Why: only after `git worktree add` + metadata registration is the path safe for a runtime PTY to boot the agent while setup runs alongside. - const terminal = await runtime.createTerminal(`id:${worktree.id}`, { - command: sequencedStartup.command, - ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), - env: sequencedStartup.env, - ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), - ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), - ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), - startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry, - // Why: the submitting renderer decides whether to open the workspace; activating here yanked users who moved on (#9944). - surfaceOwner: false - }) + const terminal = await runtime.createTerminal( + `id:${worktree.id}`, + { + command: sequencedStartup.command, + ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), + ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), + ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry, + // Why: the submitting renderer decides whether to open the workspace; activating here yanked users who moved on (#9944). + surfaceOwner: false + }, + worktree + ) startupTerminalHandle = terminal.handle startupTerminal = { spawned: true, @@ -454,21 +458,29 @@ async function spawnLocalStartupAndSetupTerminals(args: { if (!startupTerminalHandle) { throw new Error('startup_terminal_missing') } - await runtime.splitTerminal(startupTerminalHandle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: setup.envVars, - activate: false, - surfaceOwner: false - }) + await runtime.splitTerminal( + startupTerminalHandle, + { + direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', + command: setupCommand, + env: setup.envVars, + activate: false, + surfaceOwner: false + }, + worktree + ) } else { - await runtime.createTerminal(`id:${worktree.id}`, { - title: 'Setup', - command: setupCommand, - env: setup.envVars, - activate: false, - surfaceOwner: false - }) + await runtime.createTerminal( + `id:${worktree.id}`, + { + title: 'Setup', + command: setupCommand, + env: setup.envVars, + activate: false, + surfaceOwner: false + }, + worktree + ) } didSpawnSetup = true } catch (error) { diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index 7da7756bd11..96e1b804194 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -485,7 +485,7 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).toHaveBeenCalledTimes(listWorktreesCallsAfterCreate) }) - it('completes a create the listing failed and keeps sibling worktrees authorized', async () => { + it('verifies a create without re-listing and keeps sibling worktrees authorized', async () => { const sibling = { path: '/workspace/existing-sibling', head: 'sib123', @@ -504,8 +504,9 @@ describe('registerWorktreeHandlers', () => { sibling ]) await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'existing-sibling' }) + const listingCalls = listWorktreesMock.mock.calls.length - // The create Git could no longer list, recovered by reading the worktree directly. + // Only the new checkout needs verification, even when the full listing is unavailable. listWorktreesMock.mockRejectedValue(new Error('git worktree list timed out.')) describeCreatedWorktreeMock.mockResolvedValue({ path: '/workspace/improve-dashboard', @@ -526,6 +527,7 @@ describe('registerWorktreeHandlers', () => { await expect( resolveRegisteredWorktreePath('/workspace/improve-dashboard', store as never) ).resolves.toBe(resolve('/workspace/improve-dashboard')) + expect(listWorktreesMock).toHaveBeenCalledTimes(listingCalls) }) it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => { @@ -687,7 +689,8 @@ describe('registerWorktreeHandlers', () => { request_kind: 'new' }, surfaceOwner: false - } + }, + expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) ) expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith( 2, @@ -701,7 +704,8 @@ describe('registerWorktreeHandlers', () => { }, activate: false, surfaceOwner: false - } + }, + expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) ) const startupCreateCall = runtimeStub.createTerminal.mock.calls[0] const setupCreateCall = runtimeStub.createTerminal.mock.calls[1] @@ -818,13 +822,17 @@ describe('registerWorktreeHandlers', () => { expect(runtimeStub.createTerminal).toHaveBeenCalledTimes(1) // A user who moved on must not be scrolled to the new workspace by its setup pane (#9944). - expect(runtimeStub.splitTerminal).toHaveBeenCalledWith('term-startup', { - direction: 'vertical', - command: expect.stringContaining('setup-runner.sh'), - env: expect.any(Object), - activate: false, - surfaceOwner: false - }) + expect(runtimeStub.splitTerminal).toHaveBeenCalledWith( + 'term-startup', + { + direction: 'vertical', + command: expect.stringContaining('setup-runner.sh'), + env: expect.any(Object), + activate: false, + surfaceOwner: false + }, + expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) + ) }) it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => { diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 1708a561c23..029ffc6171a 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -211,9 +211,10 @@ describe('registerWorktreeHandlers', () => { 'origin/main', { wslDistro: 'Ubuntu' } ) - expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { - wslDistro: 'Ubuntu' - }) + expect(listWorktreesMock).toHaveBeenCalledWith( + '/workspace/repo', + expect.objectContaining({ wslDistro: 'Ubuntu' }) + ) expectEveryGitCallRoutedTo('Ubuntu') }) diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts index c1bcbbf3d59..bbc7eb2b25e 100644 --- a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -20,7 +20,8 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo baseBranch: args.baseBranch, runtime, gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo), - prepareCheckout: (base) => prepareWorktreeCreateForRepo(store, repo, base) + prepareCheckout: (base, beforeMaterialization) => + prepareWorktreeCreateForRepo(store, repo, base, beforeMaterialization) }) } catch { // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 1629c529921..2f7e59a6d1f 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -204,25 +204,31 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup) } - protected async provisionManagedWorktreeTerminals(args: { - worktreeSelector: string - worktreeId: string - worktreePath: string - setup?: CreateWorktreeResult['setup'] - defaultTabs?: CreateWorktreeResult['defaultTabs'] - primaryTerminalHandle?: string | null - hasStartupTerminal: boolean - setupCommandPlatform: 'windows' | 'posix' - observeSetupCompletion?: boolean - // Why: when the agent startup is sequenced to wait for setup - // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds - // the setup command. Pass that wrapped command through so the Setup tab runs - // the same script the agent is waiting on instead of a bare runner. - wrappedSetupCommand?: string - // Why: a workspace provisioned in the background must not pull the sidebar - // to itself; the user never asked to look at these tabs. - surfaceOwner?: false - }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { - return provisionWorktreeTerminals(this.getWorktreeTerminalProvisioningHost(), args) + protected async provisionManagedWorktreeTerminals( + args: { + worktreeSelector: string + worktreeId: string + worktreePath: string + setup?: CreateWorktreeResult['setup'] + defaultTabs?: CreateWorktreeResult['defaultTabs'] + primaryTerminalHandle?: string | null + hasStartupTerminal: boolean + setupCommandPlatform: 'windows' | 'posix' + observeSetupCompletion?: boolean + // Why: when the agent startup is sequenced to wait for setup + // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds + // the setup command. Pass that wrapped command through so the Setup tab runs + // the same script the agent is waiting on instead of a bare runner. + wrappedSetupCommand?: string + // Why: a workspace provisioned in the background must not pull the sidebar + // to itself; the user never asked to look at these tabs. + surfaceOwner?: false + }, + createdWorktree?: Worktree + ): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { + return provisionWorktreeTerminals( + this.getWorktreeTerminalProvisioningHost(createdWorktree), + args + ) } } diff --git a/src/main/runtime/orca-runtime-create-base-prefetch.test.ts b/src/main/runtime/orca-runtime-create-base-prefetch.test.ts index d65209b9cc4..4390ded16d6 100644 --- a/src/main/runtime/orca-runtime-create-base-prefetch.test.ts +++ b/src/main/runtime/orca-runtime-create-base-prefetch.test.ts @@ -118,7 +118,8 @@ describe('prefetchManagedWorktreeCreateBase (orca-runtime-get-worktree-terminal- expect(mocks.prepareWorktreeCreateForRepo).toHaveBeenCalledWith( expect.anything(), repo, - 'origin/main' + 'origin/main', + undefined ) }) }) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index d0885a1dfe1..ce2e4d1bc2f 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -241,10 +241,10 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork warning, ports: { canSpawn: Boolean(this.ptyController?.spawn), - createTerminal: (selector, options) => this.createTerminal(selector, options), + createTerminal: (selector, options) => this.createTerminal(selector, options, worktree), pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), - provision: (options) => this.provisionManagedWorktreeTerminals(options), + provision: (options) => this.provisionManagedWorktreeTerminals(options, worktree), activate: (repoId, worktreeId, activationSetup, startup, activationDefaultTabs) => this.notifyActivateWorktree( repoId, diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 20172740460..9175fa8dec5 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -1,14 +1,17 @@ export type { TerminalCreateOptions } from './runtime-terminal-contracts' +export type { Worktree } from '../../shared/worktree/types' export type { RuntimeTerminalCreate } from '../../shared/runtime-types' export { createTerminalRevealWarning, ownerSurfacing, resolveTerminalPresentation } from './orca-runtime-core' -export { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' -export { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' +export { makePaneKey } from '../../shared/stable-pane-id' export { randomUUID } from 'node:crypto' -export { admitStablePaneAdoption } from './runtime-terminal-pane-identity' +export { + admitStablePaneAdoption, + allocateTerminalPaneIdentity +} from './runtime-terminal-pane-identity' export { copySleepingAgentLaunchConfig, inferCapturedClaudeAgentTeamsMode, diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 9cd955428ee..6a56bff56b0 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -2,18 +2,20 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal' import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' +import type { Worktree } from '../../shared/worktree/types' export async function createDesktopTerminal( runtime: OrcaRuntimeWithCreateTerminal, worktreeSelector: string | undefined, opts: dependencies.TerminalCreateOptions, presentation: RuntimeTerminalPresentation | undefined, - rendererWindow: Electron.BrowserWindow | null + rendererWindow: Electron.BrowserWindow | null, + createdWorktree?: Worktree ): Promise { runtime.assertGraphReady() const win = rendererWindow ?? runtime.getAuthoritativeWindow() const workspace = worktreeSelector - ? await runtime.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + ? await runtime.resolveTerminalWorkspaceLaunchScope(worktreeSelector, createdWorktree) : null const launchOpts = workspace ? await runtime.resolveAgentTerminalCreateOptions(workspace, opts) diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 242e0f7f454..c8f965fcf7e 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -9,14 +9,15 @@ import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surf export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication { async createTerminal( worktreeSelector?: string, - opts: dependencies.TerminalCreateOptions = {} + opts: dependencies.TerminalCreateOptions = {}, + // Internal creation evidence; RPC and preload callers never supply it. + created?: dependencies.Worktree ): Promise { if (opts.startupAgent && worktreeSelector === undefined) { throw new Error(`startupAgent ${opts.startupAgent} requires a workspace selector.`) } const callerColors = dependencies.normalizeColorQueryReplyColors(opts.terminalColorQueryReplies) - // Why: only a paired client sends colours here; a client that predates - // terminal.setViewerColors reports its theme to a headless host only this way. + // Older paired clients report their theme only at creation. if (callerColors) { dependencies.setPairedViewerColors(callerColors) } @@ -33,22 +34,14 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') } - const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector, created) const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) const reportPtySpawnCommitted = createPtySpawnCommitReporter(launchOpts.onPtySpawnCommitted) const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path let preAllocatedHandle = launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle() - const hintedTabId = launchOpts.tabId?.trim() - const canAdoptPaneIdentity = - hintedTabId !== undefined && - dependencies.isValidHostTerminalTabId(hintedTabId) && - launchOpts.leafId !== undefined && - dependencies.isTerminalLeafId(launchOpts.leafId) - let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : dependencies.randomUUID() - let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : dependencies.randomUUID() - let paneKey = dependencies.makePaneKey(tabId, leafId) + let { tabId, leafId, paneKey } = dependencies.allocateTerminalPaneIdentity(launchOpts) const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({ worktreeId: workspace.id, connectionId: workspace.connectionId, @@ -299,6 +292,13 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate } // The renderer owns this spawn, so this process cannot see when it is requested. opts.onPtySpawnDispatched?.() - return createDesktopTerminal(this, worktreeSelector, opts, presentation, rendererWindow) + return createDesktopTerminal( + this, + worktreeSelector, + opts, + presentation, + rendererWindow, + created + ) } } diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index 7f42304eb8b..30542bc6ff8 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -1,23 +1,21 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithActivateManagedWorktree } from './orca-runtime-activate-managed-worktree' -import type { - WorktreeProvisionTerminalOptions, - WorktreeTerminalProvisioningHost -} from './runtime-worktree-terminal-provisioning' -import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import type { WorktreeTerminalProvisioningHost } from './runtime-worktree-terminal-provisioning' import type { WorktreeStartupReadinessHost } from './runtime-worktree-startup-readiness' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { prepareWorktreeCreateForRepo } from '../worktree-create-preparation' import { getWorktreeCreatePrefetchGitOptions } from '../project-runtime-git-options' +import type { Worktree } from '../../shared/worktree/types' export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRuntimeWithActivateManagedWorktree { - protected getWorktreeTerminalProvisioningHost(): WorktreeTerminalProvisioningHost { + protected getWorktreeTerminalProvisioningHost( + createdWorktree?: Worktree + ): WorktreeTerminalProvisioningHost { return { canSpawn: () => Boolean(this.ptyController?.spawn), createTerminal: (selector, options) => - this.createTerminal(selector, options as TerminalCreateOptions), - splitTerminal: (handle, options) => - this.splitTerminal(handle, options as WorktreeProvisionTerminalOptions), + this.createTerminal(selector, options, createdWorktree), + splitTerminal: (handle, options) => this.splitTerminal(handle, options, createdWorktree), setTabColor: async (worktreeId, tabId, color) => { await this.setMobileSessionTabProps(`id:${worktreeId}`, { tabId, color }) }, @@ -55,7 +53,8 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt baseBranch: args.baseBranch, runtime: this, gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo), - prepareCheckout: (base) => prepareWorktreeCreateForRepo(store, repo, base) + prepareCheckout: (base, beforeMaterialization) => + prepareWorktreeCreateForRepo(store, repo, base, beforeMaterialization) }) } } diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index decc28aa45b..960e36bd78d 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -25,6 +25,8 @@ import { getExplicitWorktreeIdSelector } from './runtime-worktree-selection' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { WorktreeIdRequiresFullPathError } from './runtime-worktree-lineage-resolution' import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' +import type { Worktree } from '../../shared/worktree/types' +import { resolveCreatedWorktreeTerminalTarget } from './runtime-created-worktree-terminal-target' export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extends OrcaRuntimeWithTransitionGraphReloadToTerminalState { protected resolveBrowserNetworkExecutionHostForWorktree(worktree?: { @@ -88,13 +90,15 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend } protected async resolveTerminalWorkspaceLaunchScope( - selector: string + selector: string, + createdWorktree?: Worktree ): Promise { - return (await this.resolveTerminalWorkspaceLaunchTarget(selector)).scope + return (await this.resolveTerminalWorkspaceLaunchTarget(selector, createdWorktree)).scope } protected async resolveTerminalWorkspaceLaunchTarget( - selector: string + selector: string, + createdWorktree?: Worktree ): Promise { const floatingTerminalSelector = selector === FLOATING_TERMINAL_WORKTREE_ID || @@ -124,7 +128,9 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector const parsed = parseWorkspaceKey(workspaceSelector) const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = + resolveCreatedWorktreeTerminalTarget(this.store, selector, createdWorktree) ?? + (await this.resolveWorktreeSelector(worktreeSelector)) // Why: `getRepo(id)` is host-blind and the same repo id can exist on local, SSH and runtime // hosts. Reading `connectionId` off an arbitrary row reports "local" for a remote worktree and // spawns its PTY on the client with the remote cwd (#11163). Loss of a usable answer is diff --git a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts index 8fe43de6950..6b9fd364b04 100644 --- a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts +++ b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts @@ -7,6 +7,7 @@ import { makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' import { randomUUID } from 'node:crypto' import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core' +import type { Worktree } from '../../shared/worktree/types' export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitTerminal { protected async splitPtyBackedTerminal( @@ -21,7 +22,8 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT // workspace, for splits the user never asked to see. surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource - } = {} + } = {}, + createdWorktree?: Worktree ): Promise { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') @@ -35,7 +37,10 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT throw new Error('terminal_handle_stale') } const direction = opts.direction ?? 'horizontal' - const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`) + const workspace = await this.resolveTerminalWorkspaceLaunchScope( + `id:${pty.worktreeId}`, + createdWorktree + ) const sourceAuthority = this.resolveTerminalSplitSourceAuthority( workspace.id, parentTabId, diff --git a/src/main/runtime/orca-runtime-split-terminal.ts b/src/main/runtime/orca-runtime-split-terminal.ts index f259f56e1a6..7dc006ef24e 100644 --- a/src/main/runtime/orca-runtime-split-terminal.ts +++ b/src/main/runtime/orca-runtime-split-terminal.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithStopExplicitlyClosedTabPtys } from './orca-runtime-stop- import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { RuntimeTerminalSplit } from '../../shared/runtime-types' import { randomUUID } from 'node:crypto' +import type { Worktree } from '../../shared/worktree/types' export class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopExplicitlyClosedTabPtys { async splitTerminal( @@ -17,11 +18,13 @@ export class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopExplicitlyC // workspace, for splits the user never asked to see. surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource - } = {} + } = {}, + // Internal creation evidence; RPC and preload callers never supply it. + createdWorktree?: Worktree ): Promise { const livePty = this.getLivePtyForHandle(handle) if (livePty) { - return await this.splitPtyBackedTerminal(livePty.pty, opts) + return await this.splitPtyBackedTerminal(livePty.pty, opts, createdWorktree) } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 6d57069feab..79ff0be6698 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -6,6 +6,7 @@ import { closeLocalWatcherForWorktreePathMock, computeWorktreePathMock, deleteWorktreeHistoryDirMock, + describeCreatedWorktree, ensurePathWithinWorkspaceMock, findExistingWorktreeSymlinkPathsMock, forgetLocalWatcherRemovalSnapshotMock, @@ -330,7 +331,7 @@ describe('OrcaRuntimeService', () => { } computeWorktreePathMock.mockReturnValue(createdWorktree.path) ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) - vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) + vi.mocked(describeCreatedWorktree).mockResolvedValue(createdWorktree) const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } @@ -353,6 +354,7 @@ describe('OrcaRuntimeService', () => { return { stdout: '', stderr: '' } }) + const inventoryCallsBefore = vi.mocked(listWorktrees).mock.calls.length try { const result = await runtime.createManagedWorktree({ repoSelector: 'id:repo-1', @@ -446,7 +448,13 @@ describe('OrcaRuntimeService', () => { branchName: 'contributor/runtime-wsl', remoteUrl: 'git@github.com:contributor/orca.git' }) - expect(listWorktrees).toHaveBeenCalledWith(TEST_REPO_PATH, { wslDistro: 'Ubuntu' }) + expect(describeCreatedWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'runtime-wsl', + { wslDistro: 'Ubuntu' } + ) + expect(listWorktrees).toHaveBeenCalledTimes(inventoryCallsBefore) } finally { gitSpy.mockRestore() } diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts index 6a0a99f4ee8..eca4bd12514 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts @@ -94,7 +94,13 @@ describe('OrcaRuntimeService', () => { expect(runHook).not.toHaveBeenCalled() expect(createTerminal).toHaveBeenCalledWith( `id:${result.worktree.id}`, - expect.objectContaining({ viewMode: 'chat' }) + expect.objectContaining({ viewMode: 'chat' }), + expect.objectContaining({ + id: result.worktree.id, + path: result.worktree.path, + repoId: result.worktree.repoId, + identity: result.worktree.identity + }) ) // Why: setup is provisioned fire-and-forget; the wait-for-setup guarantee comes from the shell nonce/marker, not JS spawn ordering. await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) diff --git a/src/main/runtime/runtime-created-worktree-terminal-target.test.ts b/src/main/runtime/runtime-created-worktree-terminal-target.test.ts new file mode 100644 index 00000000000..dd7162e98c1 --- /dev/null +++ b/src/main/runtime/runtime-created-worktree-terminal-target.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Worktree } from '../../shared/worktree/types' +import type { WorktreeLineage } from '../../shared/worktree/lineage-types' +import type { RuntimeStore } from './runtime-store-contract' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import { mergeWorktree } from '../ipc/worktree-metadata-merge' +import { resolveCreatedWorktreeTerminalTarget } from './runtime-created-worktree-terminal-target' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } +})) + +import { OrcaRuntimeService } from './orca-runtime' + +class StartupRuntime extends OrcaRuntimeService { + readonly lookup = vi.fn<(selector: string) => Promise>() + + protected override resolveWorktreeSelector(selector: string): Promise { + return this.lookup(selector) + } +} + +function owner(hostId: ExecutionHostId = 'local'): Repo { + return { + id: 'repo-1', + path: '/repos/app', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + executionHostId: hostId + } +} + +function makeFixture(repos: Repo[] = [owner()], hostId: ExecutionHostId = 'local') { + const meta: WorktreeMeta = { + instanceId: 'new-instance', + hostId, + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + const worktree = mergeWorktree( + 'repo-1', + { + path: '/workspaces/new', + head: 'abc123', + branch: 'refs/heads/new', + isBare: false, + isMainWorktree: false + }, + meta + ) + const metadata: Record = { [worktree.id]: meta } + const lineageById: Record = {} + const store: RuntimeStore = { + getRepos: () => repos, + getRepo: (id) => repos.find((repo) => repo.id === id), + addRepo: vi.fn(), + updateRepo: vi.fn(), + getAllWorktreeMeta: () => metadata, + getWorktreeMeta: (id) => metadata[id], + getAllWorktreeLineage: () => lineageById, + setWorktreeMeta: (id, patch) => (metadata[id] = { ...meta, ...metadata[id], ...patch }), + removeWorktreeMeta: (id) => { + delete metadata[id] + }, + getGitHubCache: vi.fn(), + getSettings: () => ({ + workspaceDir: '/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } + const runtime = new StartupRuntime(store) + const fallback: ResolvedWorktree = { + ...worktree, + git: worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + } + runtime.lookup.mockResolvedValue(fallback) + let ptySequence = 0 + const spawn = vi.fn(async () => ({ id: `new-pty-${++ptySequence}` })) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + return { runtime, store, worktree, meta, metadata, lineageById, spawn } +} + +describe('created worktree terminal evidence', () => { + it('starts a terminal in the new checkout without querying the worktree inventory', async () => { + const { runtime, worktree, spawn } = makeFixture() + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }, worktree) + expect(runtime.lookup).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: worktree.path, + worktreeId: worktree.id, + connectionId: null, + initiallyHidden: true + }) + ) + }) + + it('still resolves normally without internal creation evidence', async () => { + const { runtime, worktree } = makeFixture() + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }) + expect(runtime.lookup).toHaveBeenCalledOnce() + }) + + it('starts a setup split without another worktree inventory query', async () => { + const { runtime, worktree, spawn } = makeFixture() + const terminal = await runtime.createTerminal( + `id:${worktree.id}`, + { presentation: 'background' }, + worktree + ) + await runtime.splitTerminal(terminal.handle, { surfaceOwner: false }, worktree) + expect(runtime.lookup).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenLastCalledWith( + expect.objectContaining({ cwd: worktree.path, worktreeId: worktree.id, connectionId: null }) + ) + }) + + it('uses ordinary resolution when the created instance has been replaced', async () => { + const { runtime, worktree, metadata } = makeFixture() + metadata[worktree.id] = { ...metadata[worktree.id]!, instanceId: 'replacement' } + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }, worktree) + expect(runtime.lookup).toHaveBeenCalledOnce() + }) + + it('does not reuse creation evidence for a different selector or a replaced instance', async () => { + const { store, worktree, metadata } = makeFixture() + expect(resolveCreatedWorktreeTerminalTarget(store, 'branch:new', worktree)).toBeNull() + expect( + resolveCreatedWorktreeTerminalTarget(store, 'id:repo-1::/elsewhere', worktree) + ).toBeNull() + metadata[worktree.id] = { ...metadata[worktree.id]!, instanceId: 'replacement' } + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull() + }) + + it('routes an SSH-owned created checkout through its host when repo ids collide', async () => { + const { runtime, worktree, spawn } = makeFixture([owner(), owner('ssh:builder')], 'ssh:builder') + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }, worktree) + expect(runtime.lookup).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'builder' })) + }) + + it('checks the owner host metadata when a rival host shares the same locator', async () => { + const { runtime, store, worktree, meta, metadata, spawn } = makeFixture( + [owner(), owner('ssh:builder')], + 'ssh:builder' + ) + Object.assign(store, { + getWorktreeMetaForHost: (id: string, hostId: ExecutionHostId) => + id === worktree.id && hostId === 'ssh:builder' ? meta : undefined + }) + metadata[worktree.id] = { ...meta, hostId: 'local', instanceId: 'rival-instance' } + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }, worktree) + expect(runtime.lookup).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'builder' })) + }) + + it('rejects another host metadata and ambiguous legacy ownership', async () => { + const { store, worktree, metadata } = makeFixture([owner(), owner('ssh:builder')]) + metadata[worktree.id] = { ...metadata[worktree.id]!, hostId: 'ssh:builder' } + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull() + metadata[worktree.id] = { ...metadata[worktree.id]!, hostId: undefined } + const unstamped: Worktree = { ...worktree, hostId: undefined } + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, unstamped)).toBeNull() + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull() + }) + + it('keeps folder workspaces on their existing launch lookup', () => { + const { store, worktree } = makeFixture([{ ...owner(), kind: 'folder' }]) + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull() + }) + + it('preserves stored lineage while rejecting stale parents and cycles', () => { + const { store, worktree, meta, metadata, lineageById } = makeFixture() + const parentId = 'repo-1::/workspaces/parent' + const childId = 'repo-1::/workspaces/child' + metadata[parentId] = { ...meta, instanceId: 'parent-instance' } + metadata[childId] = { ...meta, instanceId: 'child-instance' } + const lineage: WorktreeLineage = { + worktreeId: worktree.id, + worktreeInstanceId: 'new-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + lineageById[worktree.id] = lineage + lineageById[childId] = { + ...lineage, + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: worktree.id, + parentWorktreeInstanceId: 'new-instance' + } + const resolve = () => resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree) + expect(resolve()).toEqual( + expect.objectContaining({ parentWorktreeId: parentId, childWorktreeIds: [childId], lineage }) + ) + metadata[parentId] = { ...meta, instanceId: 'replacement' } + expect(resolve()).toEqual(expect.objectContaining({ parentWorktreeId: null, lineage: null })) + metadata[parentId] = { ...meta, instanceId: 'parent-instance' } + lineageById[parentId] = { + ...lineage, + worktreeId: parentId, + worktreeInstanceId: 'parent-instance', + parentWorktreeId: worktree.id, + parentWorktreeInstanceId: 'new-instance' + } + expect(resolve()).toEqual(expect.objectContaining({ parentWorktreeId: null, lineage: null })) + }) +}) diff --git a/src/main/runtime/runtime-created-worktree-terminal-target.ts b/src/main/runtime/runtime-created-worktree-terminal-target.ts new file mode 100644 index 00000000000..7f24f9261e0 --- /dev/null +++ b/src/main/runtime/runtime-created-worktree-terminal-target.ts @@ -0,0 +1,106 @@ +import type { Store } from '../persistence' +import type { Worktree } from '../../shared/worktree/types' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' +import { resolveWorktreeHostRouting } from './worktree-launch-host-repo' +import { isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership' +import { isFolderRepo } from '../../shared/repo-kind' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Repo } from '../../shared/repo-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { mergeWorktree } from '../ipc/worktree-metadata-merge' +import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' + +type CreationEvidenceStore = Pick & + Partial> + +function readOwnedMetadata( + store: CreationEvidenceStore, + worktreeId: string, + repo: Repo, + hostId: ExecutionHostId, + repoOwnerCount: number +): WorktreeMeta | undefined { + const qualifiedMeta = readWorktreeMetaForHost(store, worktreeId, hostId) + const meta = qualifiedMeta ?? store.getWorktreeMeta(worktreeId) + return meta && + (!meta.hostId || meta.hostId === hostId) && + (qualifiedMeta || isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount)) + ? meta + : undefined +} + +/** Reuse same-operation creation evidence only while its persisted instance and host still agree. */ +export function resolveCreatedWorktreeTerminalTarget( + store: CreationEvidenceStore | null | undefined, + selector: string, + worktree: Worktree | undefined +): ResolvedWorktree | null { + if (!store || !worktree || selector !== `id:${worktree.id}` || !worktree.instanceId) { + return null + } + const parsed = splitWorktreeIdForFilesystem(worktree.id) + if (parsed?.repoId !== worktree.repoId || parsed.worktreePath !== worktree.path) { + return null + } + const repos = store.getRepos() + const routing = resolveWorktreeHostRouting(repos, worktree) + if (routing.kind !== 'resolved' || !routing.repo || isFolderRepo(routing.repo)) { + return null + } + const repoOwnerCount = repos.filter((repo) => repo.id === worktree.repoId).length + const meta = readOwnedMetadata(store, worktree.id, routing.repo, routing.hostId, repoOwnerCount) + if (meta?.instanceId !== worktree.instanceId) { + return null + } + const target = mergeWorktree( + worktree.repoId, + worktree, + { ...meta, hostId: routing.hostId }, + routing.repo.displayName + ) + const lineageById = store.getAllWorktreeLineage?.() ?? {} + const relatedIds = new Set([target.id]) + for (const lineage of Object.values(lineageById)) { + if (lineage.parentWorktreeId === target.id) { + relatedIds.add(lineage.worktreeId) + } + } + // Include ancestors so the existing projection can reject stale edges and cycles. + for (const id of relatedIds) { + const parentId = lineageById[id]?.parentWorktreeId + if (parentId) { + relatedIds.add(parentId) + } + } + const rows = [target] + for (const id of relatedIds) { + if (id === target.id) { + continue + } + const parsedRelated = splitWorktreeIdForFilesystem(id) + if (parsedRelated?.repoId !== target.repoId) { + continue + } + const relatedMeta = readOwnedMetadata(store, id, routing.repo, routing.hostId, repoOwnerCount) + if (!relatedMeta) { + continue + } + rows.push( + mergeWorktree( + target.repoId, + { + path: parsedRelated.worktreePath, + head: '', + branch: '', + isBare: false, + isMainWorktree: false + }, + { ...relatedMeta, hostId: routing.hostId } + ) + ) + } + const projected = projectResolvedWorktreeLineage(rows, lineageById)[0] + return projected ? { ...projected, git: worktree } : null +} diff --git a/src/main/runtime/runtime-terminal-pane-identity.ts b/src/main/runtime/runtime-terminal-pane-identity.ts index 90909f6ef35..225ac564262 100644 --- a/src/main/runtime/runtime-terminal-pane-identity.ts +++ b/src/main/runtime/runtime-terminal-pane-identity.ts @@ -1,5 +1,24 @@ import { AgentLaunchPaneAlreadyLiveError } from '../../shared/agent-launch-pane-already-live' -import { parsePaneKey } from '../../shared/stable-pane-id' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' +import { randomUUID } from 'node:crypto' + +export function allocateTerminalPaneIdentity(opts: { tabId?: string; leafId?: string }): { + tabId: string + leafId: string + paneKey: string +} { + const hintedTabId = opts.tabId?.trim() + const hintedLeafId = opts.leafId + const canAdopt = + hintedTabId !== undefined && + isValidHostTerminalTabId(hintedTabId) && + hintedLeafId !== undefined && + isTerminalLeafId(hintedLeafId) + const tabId = canAdopt ? hintedTabId : randomUUID() + const leafId = canAdopt ? hintedLeafId : randomUUID() + return { tabId, leafId, paneKey: makePaneKey(tabId, leafId) } +} /** A caller-minted `tabId:leafId` as the pair `createTerminal` adopts; an unparsable key yields * nothing, so the runtime mints its own and the reported `paneKey` shows the caller it lost. */ diff --git a/src/main/worktree-create-base-prefetch.test.ts b/src/main/worktree-create-base-prefetch.test.ts index af80afe4b41..abc338cdad7 100644 --- a/src/main/worktree-create-base-prefetch.test.ts +++ b/src/main/worktree-create-base-prefetch.test.ts @@ -186,7 +186,7 @@ describe('prefetchWorktreeCreateBase local git routing', () => { describe('checkout and refresh overlap', () => { it.each([{}, WSL])( - 'starts one checkout before a blocked refresh finishes on %j', + 'starts one checkout with a shared refresh barrier before fetch settles on %j', async (gitOptions) => { const base = { remote: 'origin', @@ -199,11 +199,17 @@ describe('checkout and refresh overlap', () => { let release!: () => void mocks.getOrStartRemoteTrackingBaseRefresh.mockImplementation( () => - new Promise((resolve) => { - release = resolve + new Promise<{ ok: boolean }>((resolve) => { + release = () => resolve({ ok: true }) }) ) - const prepareCheckout = vi.fn().mockResolvedValue(undefined) + let materialized = false + const prepareCheckout = vi.fn( + async (_base: string, beforeMaterialization?: Promise) => { + await beforeMaterialization + materialized = true + } + ) let settled = false const result = prefetchWorktreeCreateBase({ repo, @@ -214,11 +220,16 @@ describe('checkout and refresh overlap', () => { }).finally(() => { settled = true }) - await vi.waitFor(() => expect(prepareCheckout).toHaveBeenCalledWith('origin/main')) + await vi.waitFor(() => + expect(prepareCheckout).toHaveBeenCalledWith('origin/main', expect.any(Promise)) + ) expect(settled).toBe(false) + expect(materialized).toBe(false) + expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledOnce() release() await expect(result).resolves.toBe('origin/main') - expect(prepareCheckout).toHaveBeenCalledTimes(1) + expect(materialized).toBe(true) + expect(prepareCheckout).toHaveBeenCalledOnce() } ) @@ -232,8 +243,8 @@ describe('checkout and refresh overlap', () => { let release!: () => void mocks.getOrStartRemoteTrackingBaseRefresh.mockImplementation( () => - new Promise((resolve) => { - release = resolve + new Promise<{ ok: boolean }>((resolve) => { + release = () => resolve({ ok: true }) }) ) const prepareCheckout = vi.fn().mockResolvedValue(undefined) @@ -307,6 +318,35 @@ describe('checkout and refresh overlap', () => { release() await assertion }) + + it('settles the materialization barrier on fetch failure without hiding the fetch error', async () => { + mocks.resolveRemoteTrackingBase.mockResolvedValue({ + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + mocks.hasRemoteTrackingRef.mockResolvedValue(true) + const error = new Error('offline') + mocks.getOrStartRemoteTrackingBaseRefresh.mockRejectedValue(error) + let materialized = false + const prepareCheckout = vi.fn(async (_base: string, beforeMaterialization?: Promise) => { + await beforeMaterialization + materialized = true + }) + await expect( + prefetchWorktreeCreateBase({ + repo, + baseBranch: 'origin/main', + runtime: runtime(), + gitOptions: {}, + prepareCheckout + }) + ).rejects.toBe(error) + expect(materialized).toBe(true) + expect(prepareCheckout).toHaveBeenCalledOnce() + expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledOnce() + }) }) it('does not prepare folder repositories', async () => { diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 8db177a3e9c..c322ce077c8 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -33,7 +33,7 @@ type WorktreeCreateBasePrefetchRuntime = { repoPath: string, base: RemoteTrackingBaseForPrefetch, options?: WorktreeCreateBaseGitOptions - ) => Promise + ) => Promise<{ ok: boolean }> fetchRemoteWithCache: ( repoPath: string, remote: string, @@ -46,7 +46,7 @@ async function prefetchLocalWorktreeCreateBase( baseBranch: string | undefined, runtime: WorktreeCreateBasePrefetchRuntime, options: WorktreeCreateBaseGitOptions, - prepareLocalCheckout: (base: string) => void + prepareLocalCheckout: (base: string, beforeMaterialization?: Promise) => void ): Promise { // Keep host-routed calls at their original arity so they stay on the runtime's default options. const optionArgs: [] | [WorktreeCreateBaseGitOptions] = options.wslDistro ? [options] : [] @@ -89,19 +89,24 @@ async function prefetchLocalWorktreeCreateBase( remoteTrackingBase, ...optionArgs ) - if (hasTrackingRef) { - // Finalization revalidates the refreshed commit before exposing the checkout. - prepareLocalCheckout(resolvedBaseBranch) - } if ( hasTrackingRef || !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch, options)) ) { - await runtime.getOrStartRemoteTrackingBaseRefresh( + const refresh = runtime.getOrStartRemoteTrackingBaseRefresh( repo.path, remoteTrackingBase, ...optionArgs ) + if (hasTrackingRef) { + // Register during fetch, then materialize the settled base once; offline keeps its local tip. + const beforeMaterialization = refresh.then( + () => {}, + () => {} + ) + prepareLocalCheckout(resolvedBaseBranch, beforeMaterialization) + } + await refresh return resolvedBaseBranch } } @@ -124,7 +129,7 @@ export async function prefetchWorktreeCreateBase(args: { /** Routing for the project's Git host; required so a caller cannot silently * warm up the wrong ref store — pass `{}` for host Git. */ gitOptions: WorktreeCreateBaseGitOptions - prepareCheckout?: (base: string) => Promise + prepareCheckout?: (base: string, beforeMaterialization?: Promise) => Promise }): Promise { if (isFolderRepo(args.repo)) { return undefined @@ -139,12 +144,11 @@ export async function prefetchWorktreeCreateBase(args: { } const prepareCheckout = args.prepareCheckout let preparation: Promise | undefined - const prepare = (base: string): void => { + const prepare = (base: string, beforeMaterialization?: Promise): void => { if (!preparation && prepareCheckout) { - preparation = Promise.resolve() - .then(() => prepareCheckout(base)) - .catch(() => {}) + preparation = Promise.resolve().then(() => prepareCheckout(base, beforeMaterialization)) } + void preparation?.catch(() => {}) } try { const base = await prefetchLocalWorktreeCreateBase( @@ -160,6 +164,6 @@ export async function prefetchWorktreeCreateBase(args: { return base } finally { // Settle speculative work even if refresh fails; Create owns error reporting. - await preparation + await preparation?.catch(() => {}) } } diff --git a/src/main/worktree-create-preparation-cancellation.test.ts b/src/main/worktree-create-preparation-cancellation.test.ts index 7974dcb7e3d..fc171f498db 100644 --- a/src/main/worktree-create-preparation-cancellation.test.ts +++ b/src/main/worktree-create-preparation-cancellation.test.ts @@ -118,7 +118,7 @@ describe('worktree create preparation cancellation', () => { expect(signal?.aborted).toBe(true) expect((await settled)[0].status).toBe('rejected') await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, obsoletePath, {}) + expect(mocks.discard).toHaveBeenCalledWith(repo.path, obsoletePath, {}, expect.any(String)) }) it('does not retry a discard whose registration the aborted checkout already removed', async () => { diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index a72972c9d20..280be5af895 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -9,6 +9,8 @@ import { } from '../shared/worktree/create-preparation' import type { AddWorktreeOptions } from './git/worktree' import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' +import { queuePreparedWorktreeTipRefresh } from './worktree-preparation-refresh-queue' import { toHostFilesystemPath } from './host-tree-removal' import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' import { @@ -17,7 +19,8 @@ import { resetStalePreparationCleanupForTests } from './worktree-create-preparation-stale-cleanup' import { - discardPreparationWithRetry, + discardPreparationEntry, + preparationHostKey, resetPendingPreparationDiscardsForTests, trackPreparationDiscard } from './worktree-preparation-discard-retry' @@ -35,6 +38,7 @@ export type PreparationEntry = { baseBranch: string canonicalBase: string preparedPath: string + lockReason: string options: AddWorktreeOptions createdAt: number ready: Promise @@ -49,6 +53,7 @@ export type StartPreparationArgs = { baseBranch: string canonicalBase: string options: AddWorktreeOptions + beforeMaterialization?: Promise } export type DeferredPreparation = { @@ -64,38 +69,14 @@ export type PreparationClaim = { } const claims = new Set() -/** One repo on one Git host: the scope a stranded discard is retried under. */ -function preparationHostKey(repoPathKey: string, wslDistro: string): string { - return `${repoPathKey}\0${wslDistro}` -} - /** A prepared checkout is a create that is either in flight or imminent. */ export function hasPendingPreparations(): boolean { return preparations.size > 0 || claims.size > 0 || hasPendingStalePreparationCleanup() } -function pathOps(path: string): Pick { - return isWindowsAbsolutePathLike(path) ? win32 : posix -} - -async function discardEntry(entry: PreparationEntry): Promise { - // A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the - // registration for the same reason the discard here can. Enrol either way. - await entry.ready.catch(() => {}) - if (!entry.checkoutStarted) { - return - } - await discardPreparationWithRetry({ - hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro), - repoPath: entry.repoPath, - preparedPath: entry.preparedPath, - options: entry.options - }) -} - function discardEntryInBackground(entry: PreparationEntry): void { // Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry. - trackPreparationDiscard(worktreePreparationGit.run(() => discardEntry(entry))) + trackPreparationDiscard(worktreePreparationGit.run(() => discardPreparationEntry(entry))) } function expireEntry(entry: PreparationEntry): void { @@ -107,16 +88,7 @@ function expireEntry(entry: PreparationEntry): void { discardEntryInBackground(entry) } -/** - * Frees a slot for an incoming preparation, preferring one the same workspace already owns. - * - * The cap is a disk bound — a prepared checkout is a full tree, ~200 MB of tracked content in the - * repo this was measured against — so it stays small. But flipping through the composer's base - * picker arms several preparations for one repo, and a plain oldest-first eviction let that churn - * throw away another project's warm checkout, which is a structural miss for anyone working across - * several repos. Evict the incoming workspace's own oldest entry first; only reach across - * workspaces when this one holds none. - */ +/** Bound full-checkout disk use, evicting the requesting workspace's oldest preparation first. */ function enforcePreparationLimit( repoPathKey: string, workspaceRootKey: string, @@ -230,7 +202,9 @@ export function startPreparation( args.options.wslDistro ?? '' ) if (existing) { - return existing.ready + return args.beforeMaterialization + ? refreshPreparationTip(existing, args.beforeMaterialization) + : existing.ready } if (deferPreparationForClaim(args, kind)) { return Promise.resolve() @@ -238,12 +212,34 @@ export function startPreparation( return worktreePreparationGit.run(() => startBackgroundPreparation(args)) } +function refreshPreparationTip( + entry: PreparationEntry, + beforeMaterialization: Promise +): Promise { + return queuePreparedWorktreeTipRefresh( + entry, + () => { + const available = preparations.get(entry.key) === entry + if (!available && ![...claims].some((claim) => claim.entry === entry)) { + return + } + if (available) { + preparations.delete(entry.key) + clearTimeout(entry.expiration) + } + discardEntryInBackground(entry) + }, + beforeMaterialization + ) +} + function startBackgroundPreparation({ repoPath, workspaceRoot, baseBranch, canonicalBase, - options + options, + beforeMaterialization }: StartPreparationArgs): Promise { const repoPathKey = preparationPathKey(repoPath) const workspaceRootKey = preparationPathKey(workspaceRoot) @@ -252,19 +248,16 @@ function startBackgroundPreparation({ enforcePreparationLimit(repoPathKey, workspaceRootKey, wslDistro) const preparationId = `${process.pid}-${randomUUID()}` const lockReason = createWorktreePreparationLockReason(preparationId) - const preparationRoot = pathOps(workspaceRoot).join( - workspaceRoot, - WORKTREE_CREATE_PREPARATION_DIRECTORY - ) - const preparedPath = pathOps(workspaceRoot).join(preparationRoot, preparationId) + const paths = isWindowsAbsolutePathLike(workspaceRoot) ? win32 : posix + const preparationRoot = paths.join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const preparedPath = paths.join(preparationRoot, preparationId) const controller = new AbortController() const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal - const entry = {} as PreparationEntry const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) expiration.unref() - Object.assign(entry, { + const entry: PreparationEntry = { key, repoPath, repoPathKey, @@ -274,28 +267,41 @@ function startBackgroundPreparation({ baseBranch, canonicalBase, preparedPath, + lockReason, options, createdAt: Date.now(), expiration, controller, checkoutStarted: false, - ready: (async () => { - await startStalePreparationCleanup( - preparationHostKey(repoPathKey, wslDistro), + ready: Promise.resolve() + } + entry.ready = (async () => { + await startStalePreparationCleanup( + preparationHostKey(repoPathKey, wslDistro), + repoPath, + options + ) + signal.throwIfAborted() + await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) + signal.throwIfAborted() + // Already canonical, so the add re-resolves nothing. + entry.checkoutStarted = true + try { + await prepareWorktreeCreateCheckout( repoPath, - options + preparedPath, + canonicalBase, + lockReason, + { ...options, signal }, + beforeMaterialization ) - signal.throwIfAborted() - await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) - signal.throwIfAborted() - // Already canonical, so the add re-resolves nothing. - entry.checkoutStarted = true - await prepareWorktreeCreateCheckout(repoPath, preparedPath, canonicalBase, lockReason, { - ...options, - signal - }) - })() - } satisfies PreparationEntry) + } catch (error) { + if (error instanceof WorktreePreparationLockOwnershipError) { + entry.checkoutStarted = false + } + throw error + } + })() preparations.set(key, entry) void entry.ready.catch(() => { if (preparations.get(key) === entry) { @@ -314,7 +320,7 @@ export async function _resetPreparationPoolForTests(): Promise { await Promise.all( entries.map(async (entry) => { clearTimeout(entry.expiration) - await discardEntry(entry) + await discardPreparationEntry(entry) }) ) await resetPendingPreparationDiscardsForTests() diff --git a/src/main/worktree-create-preparation-stale-cleanup.ts b/src/main/worktree-create-preparation-stale-cleanup.ts index d0f146eda98..3c8c39750c3 100644 --- a/src/main/worktree-create-preparation-stale-cleanup.ts +++ b/src/main/worktree-create-preparation-stale-cleanup.ts @@ -51,15 +51,25 @@ export async function startStalePreparationCleanup( nextIndex += 1 const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) - if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { + if (!worktree.lockReason || !lockOwnerPid || isProcessAlive(lockOwnerPid)) { continue } // Preserve a branch-attached final path after a crash; only detached or // still-hidden preparations are safe to discard automatically. if (worktree.branch && pathOwnerPid === null) { - await unlockPreparedWorktree(repoPath, worktree.path, reclaimOptions).catch(() => {}) + await unlockPreparedWorktree( + repoPath, + worktree.path, + reclaimOptions, + worktree.lockReason + ).catch(() => {}) } else if (pathOwnerPid === lockOwnerPid) { - await discardPreparedWorktree(repoPath, worktree.path, reclaimOptions).catch(() => {}) + await discardPreparedWorktree( + repoPath, + worktree.path, + reclaimOptions, + worktree.lockReason + ).catch(() => {}) } } } diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index e8c2bb97bce..052181da228 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -1,53 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as WorktreeLogic from './ipc/worktree-logic' -import type { Store } from './persistence' -import { hasPendingStalePreparationCleanup } from './worktree-create-preparation-stale-cleanup' +import { mocks, repo, store, flushBackgroundWork } from './__mocks__/worktree-create-preparation' +import { describe, expect, it } from 'vitest' import type { Repo } from '../shared/repo-types' +import { hasPendingStalePreparationCleanup } from './worktree-create-preparation-stale-cleanup' import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation' -import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' - -const mocks = vi.hoisted(() => ({ - mkdir: vi.fn(), - listWorktreeGraph: vi.fn(), - prepareCheckout: vi.fn(), - finalize: vi.fn(), - discard: vi.fn(), - unlock: vi.fn(), - getWorktreeOptions: vi.fn(), - computeWorkspaceRoot: vi.fn(), - computeWorkspaceRootAsync: vi.fn(), - resolveBaseRef: vi.fn(), - measureDivergence: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) -vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) -vi.mock('./git/worktree-create-preparation', () => ({ - prepareWorktreeCreateCheckout: mocks.prepareCheckout, - finalizePreparedWorktree: mocks.finalize, - discardPreparedWorktree: mocks.discard, - unlockPreparedWorktree: mocks.unlock -})) -vi.mock('./git/worktree-base-ref-probe', () => ({ - resolveLocalWorktreeBaseRef: mocks.resolveBaseRef -})) -vi.mock('./git/worktree-base-divergence', () => ({ - measureRetargetDivergence: mocks.measureDivergence -})) -vi.mock('./project-runtime-git-options', () => ({ - getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, - getWorktreeMirrorDistro: () => undefined -})) -vi.mock('./ipc/worktree-logic', async (importOriginal) => ({ - isOrphanedWorktreeError: (await importOriginal()).isOrphanedWorktreeError, - computeWorkspaceRoot: mocks.computeWorkspaceRoot, - computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, - getWorktreePathSettings: () => ({ - workspaceDir: process.platform === 'win32' ? 'C:\\workspace' : '/workspace', - nestWorkspaces: false - }) -})) - +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { _resetWorktreeCreatePreparationsForTests, consumePreparedWorktreeCreate, @@ -55,49 +11,6 @@ import { prepareWorktreeCreateForRepo } from './worktree-create-preparation' -// Evictions and retries are fire-and-forget, so let them settle before asserting. -function flushBackgroundWork(ms = 0): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -const EXISTING_REFS = new Set([ - 'refs/heads/main', - 'refs/remotes/origin/main', - 'refs/remotes/origin/release' -]) -const repo = { id: 'repo-1', path: '/repo' } as Repo -const store = { getSettings: () => ({}) } as unknown as Store - -beforeEach(() => { - mocks.mkdir.mockReset().mockResolvedValue(undefined) - mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) - mocks.prepareCheckout.mockReset().mockResolvedValue(undefined) - mocks.finalize.mockReset().mockResolvedValue({}) - mocks.discard.mockReset().mockResolvedValue(undefined) - mocks.unlock.mockReset().mockResolvedValue(undefined) - mocks.getWorktreeOptions.mockReset().mockReturnValue({}) - mocks.measureDivergence.mockReset().mockResolvedValue('within') - mocks.resolveBaseRef - .mockReset() - .mockImplementation((_repoPath: string, baseRef: string) => - resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) - ) - mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { - throw new Error('synchronous workspace-root lookup must not run on the main thread') - }) - mocks.computeWorkspaceRootAsync - .mockReset() - .mockImplementation(async (repoPath: string) => - process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(repoPath) - ? 'C:\\workspace' - : '/workspace' - ) -}) - -afterEach(async () => { - await _resetWorktreeCreatePreparationsForTests() -}) - describe('worktree create preparation registry', () => { it.each([undefined, 'Ubuntu'])( 'preserves create priority through claim probes on %s', @@ -229,7 +142,8 @@ describe('worktree create preparation registry', () => { 'feature/test', 'main', undefined, - {} + {}, + mocks.prepareCheckout.mock.calls[0]?.[3] ) }) @@ -407,7 +321,8 @@ describe('worktree create preparation registry', () => { expect.any(String), 'refs/remotes/origin/main', expect.any(String), - { ...options, signal: expect.any(AbortSignal) } + { ...options, signal: expect.any(AbortSignal) }, + undefined ) expect(mocks.finalize).toHaveBeenCalledWith( repo.path, @@ -416,7 +331,8 @@ describe('worktree create preparation registry', () => { 'feature/test', 'origin/main', undefined, - options + options, + mocks.prepareCheckout.mock.calls[0]?.[3] ) }) @@ -454,9 +370,14 @@ describe('worktree create preparation registry', () => { }) try { await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, stalePath, { - admissionTier: 'background' - }) + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + stalePath, + { + admissionTier: 'background' + }, + 'orca-create-preparation:v1:999999999:stale' + ) expect(ready).toBe(true) await prepareWorktreeCreateForRepo(store, repo, 'origin/release') expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) @@ -498,9 +419,12 @@ describe('worktree create preparation registry', () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - expect(mocks.unlock).toHaveBeenCalledWith(repo.path, '/workspace/final', { - admissionTier: 'background' - }) + expect(mocks.unlock).toHaveBeenCalledWith( + repo.path, + '/workspace/final', + { admissionTier: 'background' }, + 'orca-create-preparation:v1:999999999:stale' + ) expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, '/workspace/final', expect.anything()) }) @@ -560,6 +484,21 @@ describe('worktree create preparation registry', () => { expect(mocks.discard).toHaveBeenCalledTimes(1) }) + it('preserves another owner’s checkout when finalization loses its lock', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.finalize.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError()) + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toMatchObject({ status: 'miss', reason: 'finalize_failed' }) + expect(mocks.discard).not.toHaveBeenCalled() + }) + /** Mirrors a real create: consume, then run the deferred re-arm once the create has returned. */ async function consumeOnce(name: string): Promise { const attempt = await consumePreparedWorktreeCreate({ @@ -676,218 +615,6 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).not.toHaveBeenCalled() }) - it('retries a discard that failed while this process is still alive', async () => { - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) - - // Fill the registry so the first preparation is evicted while its owner pid is still alive. - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/five') - await flushBackgroundWork() - expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) - }) - - it('retries only the leaked paths belonging to the host being prepared', async () => { - const otherRepo = { ...repo, id: 'repo-2', path: '/other-repo' } as Repo - const unremovable = new Set() - mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { - if (unremovable.has(path)) { - throw new Error('EBUSY') - } - }) - - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedHere = mocks.prepareCheckout.mock.calls[0][1] as string - await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') - const leakedElsewhere = mocks.prepareCheckout.mock.calls[1][1] as string - unremovable.add(leakedHere) - unremovable.add(leakedElsewhere) - - // Evict through each host's own arming: eviction prefers the incoming workspace's oldest - // entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s. - for (const base of ['origin/one', 'origin/two']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - for (const base of ['origin/one', 'origin/two']) { - await prepareWorktreeCreateForRepo(store, otherRepo, base) - } - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) - expect(mocks.discard).toHaveBeenCalledWith(otherRepo.path, leakedElsewhere, {}) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) - expect(mocks.discard.mock.calls.some((call) => call[1] === leakedElsewhere)).toBe(false) - }) - - it('stops retrying a preparation that never becomes removable', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { - if (path === leakedPath) { - throw new Error('EBUSY') - } - }) - const leakedDiscards = (): number => - mocks.discard.mock.calls.filter((call) => call[1] === leakedPath).length - - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - await flushBackgroundWork() - expect(leakedDiscards()).toBe(1) - - for (const base of ['origin/four', 'origin/five', 'origin/six']) { - await prepareWorktreeCreateForRepo(store, repo, base) - await flushBackgroundWork() - } - expect(leakedDiscards()).toBe(3) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining(`could not be discarded in 3 attempts; ${leakedPath}`), - expect.any(Error) - ) - } finally { - warn.mockRestore() - } - }) - - it('retries a failed checkout whose own self-discard also left the path registered', async () => { - let failCheckout!: (error: Error) => void - mocks.prepareCheckout.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - failCheckout = reject - }) - ) - const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') - await flushBackgroundWork() - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - - // Evict it while its checkout is still in flight, so discardEntry runs on a failed preparation. - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) - failCheckout(new Error('worktree add failed')) - await failing.catch(() => {}) - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) - }) - - it('scopes retries to the WSL distro whose preparation leaked', async () => { - const unremovable = new Set() - mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { - if (unremovable.has(path)) { - throw new Error('EBUSY') - } - }) - - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedOnUbuntu = mocks.prepareCheckout.mock.calls[0][1] as string - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedOnDebian = mocks.prepareCheckout.mock.calls[1][1] as string - unremovable.add(leakedOnUbuntu) - unremovable.add(leakedOnDebian) - - // Evict through each distro's own arming: the eviction scope includes the distro, so arming - // under Ubuntu no longer reaches across and takes the Debian entry. - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) - for (const base of ['origin/one', 'origin/two']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) - await prepareWorktreeCreateForRepo(store, repo, 'origin/one') - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnDebian, { wslDistro: 'Debian' }) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) - expect(mocks.discard.mock.calls.some((call) => call[1] === leakedOnDebian)).toBe(false) - }) - - it('drops recorded discards when the registry is reset for tests', async () => { - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - // Reject on a real timer so the fire-and-forget discard is still in flight at reset. - mocks.discard.mockImplementationOnce(async () => { - await new Promise((resolve) => setTimeout(resolve, 5)) - throw new Error('EBUSY') - }) - - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - await _resetWorktreeCreatePreparationsForTests() - // Past the rejection timer: the reset must have absorbed the failure, not raced ahead of it. - await flushBackgroundWork(20) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) - }) - - it("settles an evicted preparation's discard before the reset drops the registry", async () => { - let failCheckout!: (error: Error) => void - mocks.prepareCheckout.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - failCheckout = reject - }) - ) - const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') - await flushBackgroundWork() - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { - if (path === leakedPath) { - throw new Error('EBUSY') - } - }) - - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - // The eviction's discard is still parked on the checkout, so the reset has to wait for it. - const reset = _resetWorktreeCreatePreparationsForTests() - await flushBackgroundWork(5) - failCheckout(new Error('worktree add failed')) - await failing.catch(() => {}) - await reset - await flushBackgroundWork(5) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) - }) - it('reports a pending create while a stale-cleanup scan is running', async () => { let releaseListing!: () => void mocks.listWorktreeGraph.mockReturnValueOnce( diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index 3ac022910eb..0bca1160d8e 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -9,6 +9,7 @@ import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' import { measureRetargetDivergence } from './git/worktree-base-divergence' import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' import { _resetPreparationPoolForTests, @@ -86,17 +87,19 @@ function canonicalBaseRef( export function prepareWorktreeCreateForRepo( store: Store, repo: Repo, - baseBranch: string + baseBranch: string, + beforeMaterialization?: Promise ): Promise { return worktreePreparationGit.run(() => - prepareWorktreeCreateInBackground(store, repo, baseBranch) + prepareWorktreeCreateInBackground(store, repo, baseBranch, beforeMaterialization) ) } async function prepareWorktreeCreateInBackground( store: Store, repo: Repo, - baseBranch: string + baseBranch: string, + beforeMaterialization?: Promise ): Promise { if (repo.connectionId || isFolderRepo(repo)) { return @@ -116,7 +119,8 @@ async function prepareWorktreeCreateInBackground( workspaceRoot, baseBranch, canonicalBase, - options + options, + beforeMaterialization }) } @@ -285,7 +289,8 @@ export async function consumePreparedWorktreeCreate( args.branch, args.baseBranch, args.refreshLocalBaseRef, - options + options, + entry.lockReason ) const result = args.timing ? await args.timing.time('prepared_checkout_finalize', finalize) @@ -295,7 +300,14 @@ export async function consumePreparedWorktreeCreate( const rearm = deferRearmPreparation(entry, reservation, args.baseBranch, claim.canonicalBase) return { status: 'hit', retargeted: claim.retargeted, result, rearm } } catch (error) { - await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) + if (!(error instanceof WorktreePreparationLockOwnershipError)) { + await discardPreparedWorktree( + args.repoPath, + entry.preparedPath, + options, + entry.lockReason + ).catch(() => {}) + } console.warn( '[worktree-create] prepared checkout could not be finalized; using normal add', error diff --git a/src/main/worktree-preparation-discard-retry.test.ts b/src/main/worktree-preparation-discard-retry.test.ts new file mode 100644 index 00000000000..5a4b2d8b52d --- /dev/null +++ b/src/main/worktree-preparation-discard-retry.test.ts @@ -0,0 +1,241 @@ +import { mocks, repo, store, flushBackgroundWork } from './__mocks__/worktree-create-preparation' +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../shared/repo-types' +import { + _resetWorktreeCreatePreparationsForTests, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' + +describe('worktree preparation discard retries', () => { + it('retries a discard that failed while this process is still alive', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) + + // Fill the registry so the first preparation is evicted while its owner pid is still alive. + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/five') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + }) + + it('retries only the leaked paths belonging to the host being prepared', async () => { + const otherRepo = { ...repo, id: 'repo-2', path: '/other-repo' } as Repo + const unremovable = new Set() + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (unremovable.has(path)) { + throw new Error('EBUSY') + } + }) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedHere = mocks.prepareCheckout.mock.calls[0][1] as string + await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') + const leakedElsewhere = mocks.prepareCheckout.mock.calls[1][1] as string + unremovable.add(leakedHere) + unremovable.add(leakedElsewhere) + + // Evict through each host's own arming: eviction prefers the incoming workspace's oldest + // entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s. + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, otherRepo, base) + } + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}, expect.any(String)) + expect(mocks.discard).toHaveBeenCalledWith( + otherRepo.path, + leakedElsewhere, + {}, + expect.any(String) + ) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}, expect.any(String)) + expect(mocks.discard.mock.calls.some((call) => call[1] === leakedElsewhere)).toBe(false) + }) + + it('stops retrying a preparation that never becomes removable', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (path === leakedPath) { + throw new Error('EBUSY') + } + }) + const leakedDiscards = (): number => + mocks.discard.mock.calls.filter((call) => call[1] === leakedPath).length + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await flushBackgroundWork() + expect(leakedDiscards()).toBe(1) + + for (const base of ['origin/four', 'origin/five', 'origin/six']) { + await prepareWorktreeCreateForRepo(store, repo, base) + await flushBackgroundWork() + } + expect(leakedDiscards()).toBe(3) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`could not be discarded in 3 attempts; ${leakedPath}`), + expect.any(Error) + ) + } finally { + warn.mockRestore() + } + }) + + it('retries a failed checkout whose own self-discard also left the path registered', async () => { + let failCheckout!: (error: Error) => void + mocks.prepareCheckout.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failCheckout = reject + }) + ) + const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await flushBackgroundWork() + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + + // Evict it while its checkout is still in flight, so discardEntry runs on a failed preparation. + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) + failCheckout(new Error('worktree add failed')) + await failing.catch(() => {}) + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + }) + + it('scopes retries to the WSL distro whose preparation leaked', async () => { + const unremovable = new Set() + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (unremovable.has(path)) { + throw new Error('EBUSY') + } + }) + + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedOnUbuntu = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedOnDebian = mocks.prepareCheckout.mock.calls[1][1] as string + unremovable.add(leakedOnUbuntu) + unremovable.add(leakedOnDebian) + + // Evict through each distro's own arming: the eviction scope includes the distro, so arming + // under Ubuntu no longer reaches across and takes the Debian entry. + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/one') + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + leakedOnUbuntu, + { wslDistro: 'Ubuntu' }, + expect.any(String) + ) + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + leakedOnDebian, + { wslDistro: 'Debian' }, + expect.any(String) + ) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + leakedOnUbuntu, + { wslDistro: 'Ubuntu' }, + expect.any(String) + ) + expect(mocks.discard.mock.calls.some((call) => call[1] === leakedOnDebian)).toBe(false) + }) + + it('drops recorded discards when the registry is reset for tests', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + // Reject on a real timer so the fire-and-forget discard is still in flight at reset. + mocks.discard.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + throw new Error('EBUSY') + }) + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await _resetWorktreeCreatePreparationsForTests() + // Past the rejection timer: the reset must have absorbed the failure, not raced ahead of it. + await flushBackgroundWork(20) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + }) + + it("settles an evicted preparation's discard before the reset drops the registry", async () => { + let failCheckout!: (error: Error) => void + mocks.prepareCheckout.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failCheckout = reject + }) + ) + const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await flushBackgroundWork() + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (path === leakedPath) { + throw new Error('EBUSY') + } + }) + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + // The eviction's discard is still parked on the checkout, so the reset has to wait for it. + const reset = _resetWorktreeCreatePreparationsForTests() + await flushBackgroundWork(5) + failCheckout(new Error('worktree add failed')) + await failing.catch(() => {}) + await reset + await flushBackgroundWork(5) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + }) +}) diff --git a/src/main/worktree-preparation-discard-retry.ts b/src/main/worktree-preparation-discard-retry.ts index 8602bebb39a..69ea2c4edd3 100644 --- a/src/main/worktree-preparation-discard-retry.ts +++ b/src/main/worktree-preparation-discard-retry.ts @@ -1,6 +1,8 @@ import type { AddWorktreeOptions } from './git/worktree' import { discardPreparedWorktree } from './git/worktree-create-preparation' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { isOrphanedWorktreeError } from './ipc/worktree-logic' +import type { PreparationEntry } from './worktree-create-preparation-pool' // Stale cleanup only reclaims preparations whose owner pid is dead, so a discard that fails inside // the live process would strand its scratch checkout until the app restarts. Remember the failure @@ -11,12 +13,34 @@ export type PreparationDiscardTarget = { hostKey: string repoPath: string preparedPath: string + lockReason: string options: AddWorktreeOptions } const pendingDiscards = new Map() const inFlightDiscards = new Set>() +/** One repository on one Git host owns preparation cleanup and its retries. */ +export function preparationHostKey(repoPathKey: string, wslDistro: string): string { + return `${repoPathKey}\0${wslDistro}` +} + +export async function discardPreparationEntry(entry: PreparationEntry): Promise { + await entry.ready.catch(() => {}) + if (!entry.checkoutStarted) { + return + } + // Claim cleanup before yielding so queued refresh failures cannot discard the same checkout. + entry.checkoutStarted = false + await discardPreparationWithRetry({ + hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro), + repoPath: entry.repoPath, + preparedPath: entry.preparedPath, + lockReason: entry.lockReason, + options: entry.options + }) +} + /** Keeps a fire-and-forget discard settleable by the test reset, which would otherwise race it. */ export function trackPreparationDiscard(work: Promise): void { inFlightDiscards.add(work) @@ -30,11 +54,16 @@ function pendingKey(target: PreparationDiscardTarget): string { async function runDiscard(target: PreparationDiscardTarget, attempts: number): Promise { try { - await discardPreparedWorktree(target.repoPath, target.preparedPath, target.options) + await discardPreparedWorktree( + target.repoPath, + target.preparedPath, + target.options, + target.lockReason + ) } catch (error) { // An aborted or failed checkout self-discards first, so the registration is usually already // gone by the time the pool discards; retrying that would only spawn Git to fail again. - if (isOrphanedWorktreeError(error)) { + if (error instanceof WorktreePreparationLockOwnershipError || isOrphanedWorktreeError(error)) { return } // Bounded: a path that never becomes removable must not tax every later preparation. diff --git a/src/main/worktree-preparation-fetch-barrier.test.ts b/src/main/worktree-preparation-fetch-barrier.test.ts new file mode 100644 index 00000000000..9d2c8d397a7 --- /dev/null +++ b/src/main/worktree-preparation-fetch-barrier.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { waitForPromiseWithSignal } from '../shared/abort-signal-reason' +import { flushBackgroundWork, mocks, repo, store } from './__mocks__/worktree-create-preparation' +import { + consumePreparedWorktreeCreate, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' +import { + listPreparations, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from './worktree-create-preparation-pool' + +function deferredFetch() { + let settle!: () => void + const beforeMaterialization = new Promise((resolve) => { + settle = resolve + }) + return { beforeMaterialization, settle } +} + +function consume() { + return consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/new', + branch: 'new', + baseBranch: 'origin/main' + }) +} + +describe('prepared checkout shared fetch barrier', () => { + it('passes initial materialization settlement separately from owning WSL Git options', async () => { + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + const fetch = deferredFetch() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main', fetch.beforeMaterialization) + const entry = listPreparations()[0]! + expect(mocks.prepareCheckout).toHaveBeenCalledWith( + repo.path, + entry.preparedPath, + 'refs/remotes/origin/main', + entry.lockReason, + { wslDistro: 'Ubuntu', signal: expect.any(AbortSignal) }, + fetch.beforeMaterialization + ) + expect(mocks.refreshTip).not.toHaveBeenCalled() + fetch.settle() + }) + + it('lets a cold racing consume join the first materialization without a second checkout or refresh', async () => { + const fetch = deferredFetch() + let materializations = 0 + mocks.prepareCheckout.mockImplementationOnce( + async (_repo, _path, _base, _lock, options, barrier) => { + await waitForPromiseWithSignal(barrier, options.signal) + materializations++ + } + ) + const preparation = prepareWorktreeCreateForRepo( + store, + repo, + 'origin/main', + fetch.beforeMaterialization + ) + await vi.waitFor(() => expect(mocks.prepareCheckout).toHaveBeenCalledOnce()) + const create = consume() + await flushBackgroundWork() + expect(materializations).toBe(0) + expect(mocks.finalize).not.toHaveBeenCalled() + fetch.settle() + await preparation + expect(await create).toMatchObject({ status: 'hit' }) + expect(materializations).toBe(1) + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + expect(mocks.refreshTip).not.toHaveBeenCalled() + expect(mocks.finalize).toHaveBeenCalledOnce() + }) + + it('publishes an existing checkout refresh before a racing consume can finalize', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + const oldReady = entry.ready + const fetch = deferredFetch() + const preparation = prepareWorktreeCreateForRepo( + store, + repo, + 'origin/main', + fetch.beforeMaterialization + ) + await vi.waitFor(() => expect(entry.ready).not.toBe(oldReady)) + const create = consume() + await flushBackgroundWork() + expect(mocks.refreshTip).not.toHaveBeenCalled() + expect(mocks.finalize).not.toHaveBeenCalled() + fetch.settle() + await preparation + expect(await create).toMatchObject({ status: 'hit' }) + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + expect(mocks.refreshTip).toHaveBeenCalledOnce() + expect(mocks.finalize).toHaveBeenCalledOnce() + }) + + it('expires a ready checkout while its shared fetch is still pending without waiting for the network', async () => { + vi.useFakeTimers() + const fetch = deferredFetch() + try { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + const settled = Promise.allSettled([ + prepareWorktreeCreateForRepo(store, repo, 'origin/main', fetch.beforeMaterialization) + ]) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_PREPARATION_TTL_MS) + expect((await settled)[0]?.status).toBe('rejected') + expect(mocks.refreshTip).not.toHaveBeenCalled() + expect(mocks.discard).toHaveBeenCalledOnce() + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + entry.preparedPath, + {}, + entry.lockReason + ) + expect(listPreparations()).toEqual([]) + } finally { + fetch.settle() + vi.useRealTimers() + } + }) +}) diff --git a/src/main/worktree-preparation-refresh-queue.ts b/src/main/worktree-preparation-refresh-queue.ts new file mode 100644 index 00000000000..5e9b69c9725 --- /dev/null +++ b/src/main/worktree-preparation-refresh-queue.ts @@ -0,0 +1,42 @@ +import { worktreePreparationGit } from './git/worktree-create-git-executor' +import { refreshPreparedWorktreeTip } from './git/worktree-preparation-tip-refresh' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' +import type { PreparationEntry } from './worktree-create-preparation-pool' +import { waitForPromiseWithSignal } from '../shared/abort-signal-reason' + +/** Publish the refresh before yielding so a racing create waits for the same checkout. */ +export function queuePreparedWorktreeTipRefresh( + entry: PreparationEntry, + retire: () => void, + beforeMaterialization?: Promise +): Promise { + const signal = entry.options.signal + ? AbortSignal.any([entry.options.signal, entry.controller.signal]) + : entry.controller.signal + const ready = entry.ready.then(async () => { + signal.throwIfAborted() + if (beforeMaterialization) { + await waitForPromiseWithSignal(beforeMaterialization, signal) + } + return worktreePreparationGit.run(() => + refreshPreparedWorktreeTip( + entry.repoPath, + entry.preparedPath, + entry.canonicalBase, + entry.lockReason, + { + ...entry.options, + signal + } + ) + ) + }) + entry.ready = ready + void ready.catch((error: unknown) => { + if (error instanceof WorktreePreparationLockOwnershipError) { + entry.checkoutStarted = false + } + retire() + }) + return ready +} diff --git a/src/main/worktree-preparation-tip-refresh.test.ts b/src/main/worktree-preparation-tip-refresh.test.ts new file mode 100644 index 00000000000..cc22314a8b8 --- /dev/null +++ b/src/main/worktree-preparation-tip-refresh.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from 'vitest' +import { flushBackgroundWork, mocks, repo, store } from './__mocks__/worktree-create-preparation' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' +import { + consumePreparedWorktreeCreate, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' +import { + listPreparations, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from './worktree-create-preparation-pool' + +const beforeMaterialization = Promise.resolve() + +function consume() { + return consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/new', + branch: 'new', + baseBranch: 'origin/main' + }) +} + +describe('prepared checkout tip refresh', () => { + it('refreshes the existing checkout on its Git host without preparing another tree', async () => { + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + await prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + expect(mocks.refreshTip).toHaveBeenCalledWith( + repo.path, + entry.preparedPath, + 'refs/remotes/origin/main', + entry.lockReason, + { wslDistro: 'Ubuntu', signal: expect.any(AbortSignal) } + ) + }) + + it('lets consume join an in-flight refresh before finalizing', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + let release!: () => void + mocks.refreshTip.mockImplementationOnce( + () => new Promise((resolve) => (release = resolve)) + ) + const refresh = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await vi.waitFor(() => expect(mocks.refreshTip).toHaveBeenCalledOnce()) + const create = consume() + await flushBackgroundWork() + expect(mocks.finalize).not.toHaveBeenCalled() + release() + await refresh + expect(await create).toMatchObject({ status: 'hit' }) + expect(mocks.finalize).toHaveBeenCalledOnce() + }) + + it('defers another preparation while the previous checkout is still claimed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(await consume()).toMatchObject({ status: 'hit' }) + mocks.computeWorkspaceRootAsync.mockClear() + mocks.resolveBaseRef.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + expect(mocks.computeWorkspaceRootAsync).toHaveBeenCalledOnce() + expect(mocks.resolveBaseRef).toHaveBeenCalledOnce() + expect(mocks.refreshTip).not.toHaveBeenCalled() + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + }) + + it('cancels an expired tip refresh before cleanup', async () => { + vi.useFakeTimers() + let signal: AbortSignal | undefined + try { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + mocks.refreshTip.mockImplementationOnce((_repo, _path, _base, _lock, options) => { + signal = options.signal + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { + once: true + }) + }) + }) + const refresh = prepareWorktreeCreateForRepo( + store, + repo, + 'origin/main', + beforeMaterialization + ) + const settled = Promise.allSettled([refresh]) + await vi.advanceTimersByTimeAsync(0) + expect(signal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_PREPARATION_TTL_MS) + expect(signal?.aborted).toBe(true) + expect((await settled)[0]?.status).toBe('rejected') + expect(mocks.discard).toHaveBeenCalledOnce() + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + entry.preparedPath, + {}, + entry.lockReason + ) + expect(listPreparations()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('cancels an evicted tip refresh and preserves its original cleanup scope', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + let signal: AbortSignal | undefined + mocks.refreshTip.mockImplementationOnce((_repo, _path, _base, _lock, options) => { + signal = options.signal + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { + once: true + }) + }) + }) + const settled = Promise.allSettled([ + prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + ]) + await vi.waitFor(() => expect(mocks.refreshTip).toHaveBeenCalledOnce()) + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + expect(signal?.aborted).toBe(true) + expect((await settled)[0]?.status).toBe('rejected') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledOnce() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, entry.preparedPath, {}, entry.lockReason) + }) + + it('preserves a checkout whose lock was taken over', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.refreshTip.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError()) + await expect( + prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + ).rejects.toThrow('lock owner changed') + await flushBackgroundWork() + expect(listPreparations()).toEqual([]) + expect(mocks.discard).not.toHaveBeenCalled() + expect(await consume()).toMatchObject({ status: 'miss', reason: 'none_armed' }) + }) + + it('serializes successive fetched tips and lets consume wait for the newest queued refresh', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const order: string[] = [] + let release!: () => void + mocks.refreshTip + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => { + order.push('first-tip') + resolve() + } + }) + ) + .mockImplementationOnce(async () => { + order.push('second-tip') + }) + mocks.finalize.mockImplementationOnce(async () => { + order.push('finalize') + return {} + }) + const first = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await vi.waitFor(() => expect(mocks.refreshTip).toHaveBeenCalledOnce()) + const second = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await flushBackgroundWork() + const create = consume() + await flushBackgroundWork() + expect(mocks.refreshTip).toHaveBeenCalledOnce() + expect(mocks.finalize).not.toHaveBeenCalled() + release() + const results = await Promise.all([first, second, create]) + expect(results[2]).toMatchObject({ status: 'hit' }) + expect(order).toEqual(['first-tip', 'second-tip', 'finalize']) + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + }) + + it('discards a claimed checkout once when successive queued refreshes fail', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + let rejectRefresh = (_error: Error) => {} + mocks.refreshTip.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRefresh = reject + }) + ) + const first = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await vi.waitFor(() => expect(mocks.refreshTip).toHaveBeenCalledOnce()) + const second = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await flushBackgroundWork() + const create = consume() + const settled = Promise.allSettled([first, second]) + await flushBackgroundWork() + const finishes: (() => void)[] = [] + mocks.discard.mockImplementation( + () => + new Promise((resolve) => { + finishes.push(resolve) + }) + ) + rejectRefresh(new Error('first tip reset failed')) + await settled + const attempt = await create + expect(attempt).toMatchObject({ status: 'miss', reason: 'prepare_failed' }) + await vi.waitFor(() => expect(mocks.discard).toHaveBeenCalled()) + await flushBackgroundWork() + try { + expect(mocks.discard).toHaveBeenCalledOnce() + expect(mocks.finalize).not.toHaveBeenCalled() + } finally { + for (const finish of finishes) { + finish() + } + if (attempt.status === 'miss') { + attempt.rearm?.() + } + await flushBackgroundWork() + } + }) +}) diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 8575f0fddd2..a692c2890c0 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' @@ -13,6 +13,7 @@ import { isForEachRefExcludeUnsupportedError } from './git-ref-command-capabilit import { isNoWriteFetchHeadUnsupportedError } from './git-fetch-head-capability' import { hasUnsupportedRevParsePathFormatEcho, + isUnsupportedWorktreeAddLockReasonError, isUnsupportedWorktreeListZError } from './git-worktree-command-capabilities' import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env' @@ -206,15 +207,36 @@ describeBinaryCompatibility('real Git binary compatibility', () => { it('supports prepared worktree creation and finalization', async () => { const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() - await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-prepared', 'HEAD']) + const lockPath = join(repoPath, '.git', 'worktrees', 'compat-prepared', 'locked') + const lockReason = 'orca-create-preparation:v1:compat\n' + try { + await runGit([ + 'worktree', + 'add', + '--detach', + '--no-checkout', + '--lock', + '--reason', + lockReason.slice(0, -1), + 'compat-prepared', + 'HEAD' + ]) + expect(supports(2, 33)).toBe(true) + } catch (error) { + expect(supports(2, 33)).toBe(false) + expect(isUnsupportedWorktreeAddLockReasonError(error)).toBe(true) + await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-prepared', 'HEAD']) + await writeFile(lockPath, lockReason, { flag: 'wx' }) + } + await expect(readFile(lockPath, 'utf8')).resolves.toBe(lockReason) + await expect(readFile(join(repoPath, 'compat-prepared', 'tracked.txt'))).rejects.toThrow() await runGit(['-C', 'compat-prepared', 'reset', '--hard', 'HEAD']) - await runGit([ - 'worktree', - 'lock', - '--reason', - 'orca-create-preparation:v1:compat', - 'compat-prepared' - ]) + expect( + (await runGit(['-C', 'compat-prepared', 'rev-parse', '--git-path', 'locked'])).stdout.trim() + ).toContain('worktrees/compat-prepared/locked') + expect( + (await runGit(['-C', 'compat-prepared', 'rev-parse', '--git-common-dir'])).stdout.trim() + ).toContain('.git') // Why: `-f -f` moves a locked preparation while preserving its lock reason (Git >=2.25). await runGit(['worktree', 'move', '-f', '-f', 'compat-prepared', 'compat-final']) await runGit([ @@ -233,7 +255,8 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await expect(runGit(['-C', 'compat-final', 'rev-parse', 'HEAD'])).resolves.toMatchObject({ stdout: `${head}\n` }) - await runGit(['worktree', 'unlock', 'compat-final']) + await expect(readFile(lockPath, 'utf8')).resolves.toBe(lockReason) + await unlink(lockPath) await runGit(['worktree', 'remove', '--force', 'compat-final']) await runGit(['branch', '-D', 'compat-prepared-final']) }) @@ -433,6 +456,23 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await runGit(['config', '--unset', 'maintenance.auto']) }) + it('writes a multi-pack-index and reads packed objects at the baseline', async () => { + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + await runGit(['-c', 'gc.auto=0', 'repack', '-d']) + await expect(runGit(['multi-pack-index', 'write'])).resolves.toBeDefined() + const midx = await readFile(join(repoPath, '.git', 'objects', 'pack', 'multi-pack-index')) + expect(midx.subarray(0, 4).toString()).toBe('MIDX') + await expect(runGit(['multi-pack-index', 'verify'])).resolves.toMatchObject({ stderr: '' }) + await expect( + runGit(['-c', 'core.multiPackIndex=true', 'cat-file', '-t', head]) + ).resolves.toMatchObject({ stdout: 'commit\n' }) + await runGit(['config', 'core.multiPackIndex', 'false']) + await expect( + runGit(['config', '--bool', '--get', 'core.multiPackIndex']) + ).resolves.toMatchObject({ stdout: 'false\n' }) + await runGit(['config', '--unset', 'core.multiPackIndex']) + }) + it('fetches hosted review heads into dedicated refs', async () => { const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() await runGit(['update-ref', 'refs/pull/42/head', head]) diff --git a/src/shared/git-capability-cache.ts b/src/shared/git-capability-cache.ts index 06d0e957c74..5b77e01d1fb 100644 --- a/src/shared/git-capability-cache.ts +++ b/src/shared/git-capability-cache.ts @@ -10,6 +10,7 @@ export type GitCapability = | 'merge-tree-merge-base' | 'merge-tree-write-tree' | 'rev-parse-path-format' + | 'worktree-add-lock-reason' | 'worktree-list-z' export class GitCapabilityCache extends CapabilityProbeCache { diff --git a/src/shared/git-worktree-command-capabilities.ts b/src/shared/git-worktree-command-capabilities.ts index 76bd705432c..fa3f38dab0f 100644 --- a/src/shared/git-worktree-command-capabilities.ts +++ b/src/shared/git-worktree-command-capabilities.ts @@ -29,6 +29,12 @@ export function isUnsupportedRevParsePathFormatError(error: unknown): boolean { ) } +export function isUnsupportedWorktreeAddLockReasonError(error: unknown): boolean { + return /\b(?:unknown|invalid|unrecognized) (?:switch|option)[^\r\n]*(?:--)?reason\b/i.test( + getGitErrorText(error) + ) +} + export function hasUnsupportedRevParsePathFormatEcho(output: string): boolean { return output.split(/\r?\n/).some((line) => line.startsWith('--path-format')) } diff --git a/src/shared/repo-maintenance-schedule.ts b/src/shared/repo-maintenance-schedule.ts new file mode 100644 index 00000000000..cbc5764b695 --- /dev/null +++ b/src/shared/repo-maintenance-schedule.ts @@ -0,0 +1,69 @@ +import { BoundedMap } from './bounded-map' +import { + PACK_INDEX_MAINTENANCE_COOLDOWN_MS, + PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS, + type PackIndexMaintenanceOutcome +} from './repo-pack-index-maintenance-policy' +import { + REF_MAINTENANCE_CLEAN_COOLDOWN_MS, + type RefMaintenanceOutcome, + type RefMaintenanceSpan, + type RepoRefMaintenanceTarget +} from './repo-ref-maintenance-policy' + +export class RepoMaintenanceSchedule { + private readonly refCooldownUntil = new BoundedMap({ maxEntries: 256 }) + private readonly indexCooldownUntil = new BoundedMap({ maxEntries: 256 }) + + constructor(private readonly now: () => number) {} + + refDueAt(key: string): number { + return this.refCooldownUntil.peek(key) ?? 0 + } + + indexDueAt(key: string): number { + return this.indexCooldownUntil.get(key) ?? 0 + } + + postponeIndex(key: string, cooldownMs: number): void { + this.indexCooldownUntil.set(key, this.now() + cooldownMs) + } + + clear(): void { + this.refCooldownUntil.clear() + this.indexCooldownUntil.clear() + } + + settleRefs( + key: string, + span: RefMaintenanceSpan, + outcome: RefMaintenanceOutcome, + cooldownMs: number + ): void { + span.setAttribute('repo.maintenance_outcome', outcome) + this.refCooldownUntil.set(key, this.now() + cooldownMs) + } + + async maintain( + target: RepoRefMaintenanceTarget, + signal: AbortSignal, + span: RefMaintenanceSpan, + canWrite: () => boolean + ): Promise { + if (!target.maintainPackIndex || this.now() < this.indexDueAt(target.key)) { + return + } + const outcome = await target.maintainPackIndex(signal, span, canWrite) + if (signal.aborted || outcome === 'deferred') { + return outcome + } + const cooldown = + outcome === 'failed' + ? PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS + : outcome === 'opted_out' || outcome === 'protected' + ? REF_MAINTENANCE_CLEAN_COOLDOWN_MS + : PACK_INDEX_MAINTENANCE_COOLDOWN_MS + this.postponeIndex(target.key, cooldown) + return outcome + } +} diff --git a/src/shared/repo-pack-index-maintenance-policy.ts b/src/shared/repo-pack-index-maintenance-policy.ts new file mode 100644 index 00000000000..25795e2d2a5 --- /dev/null +++ b/src/shared/repo-pack-index-maintenance-policy.ts @@ -0,0 +1,11 @@ +export const PACK_INDEX_MAINTENANCE_COOLDOWN_MS = 60 * 60_000 +export const PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS = 30 * 60_000 + +export type PackIndexMaintenanceOutcome = + | 'written' + | 'unchanged' + | 'below_threshold' + | 'opted_out' + | 'protected' + | 'deferred' + | 'failed' diff --git a/src/shared/repo-pack-index-scheduling.test.ts b/src/shared/repo-pack-index-scheduling.test.ts new file mode 100644 index 00000000000..109e7d252a0 --- /dev/null +++ b/src/shared/repo-pack-index-scheduling.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PACK_INDEX_MAINTENANCE_COOLDOWN_MS, + PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS +} from './repo-pack-index-maintenance-policy' +import { RepoRefMaintenance } from './repo-ref-maintenance' +import { + REF_MAINTENANCE_CLEAN_COOLDOWN_MS, + REF_MAINTENANCE_PACKED_COOLDOWN_MS, + type RepoRefMaintenanceTarget +} from './repo-ref-maintenance-policy' + +const { countLooseRefsMock } = vi.hoisted(() => ({ countLooseRefsMock: vi.fn() })) +vi.mock('./loose-ref-count', () => ({ countLooseRefs: countLooseRefsMock })) + +const QUIET_MS = 10 +const engines: RepoRefMaintenance[] = [] + +function fixture(withIndex = true) { + const resolveRefsDirectory = vi.fn(async () => '/repo/.git/refs') + const packRefs = vi.fn(async () => {}) + const maintainPackIndex = vi.fn(async () => 'written' as const) + const target: RepoRefMaintenanceTarget = { + key: 'local::/repo/.git', + resolveRefsDirectory, + packRefs, + ...(withIndex ? { maintainPackIndex } : {}) + } + const maintenance = new RepoRefMaintenance({ quietPeriodMs: QUIET_MS, looseRefThreshold: 10 }) + engines.push(maintenance) + return { maintenance, target, resolveRefsDirectory, packRefs, maintainPackIndex } +} + +async function advance(maintenance: RepoRefMaintenance, milliseconds: number): Promise { + await vi.advanceTimersByTimeAsync(milliseconds) + await maintenance.whenAttemptSettled() +} + +beforeEach(() => { + vi.useFakeTimers() + countLooseRefsMock.mockResolvedValue({ count: 0, saturated: false }) +}) + +afterEach(() => { + for (const maintenance of engines.splice(0)) { + maintenance.dispose() + } + vi.useRealTimers() + vi.resetAllMocks() +}) + +describe('independent idle pack-index scheduling', () => { + it('keeps a dirty ref arm until its cooldown ends without needing another fetch', async () => { + countLooseRefsMock.mockResolvedValue({ count: 20, saturated: true }) + const { maintenance, target, packRefs } = fixture(false) + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + expect(packRefs).toHaveBeenCalledTimes(1) + maintenance.arm(target) + await advance(maintenance, REF_MAINTENANCE_PACKED_COOLDOWN_MS - 1) + expect(packRefs).toHaveBeenCalledTimes(1) + await advance(maintenance, 1) + expect(packRefs).toHaveBeenCalledTimes(2) + }) + + it('refreshes changed packs while a low-ref repository is still on ref cooldown', async () => { + const { maintenance, target, resolveRefsDirectory, maintainPackIndex } = fixture() + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + maintenance.arm(target) + await advance(maintenance, PACK_INDEX_MAINTENANCE_COOLDOWN_MS - 1) + expect(maintainPackIndex).toHaveBeenCalledTimes(1) + await advance(maintenance, 1) + expect(maintainPackIndex).toHaveBeenCalledTimes(2) + expect(resolveRefsDirectory).toHaveBeenCalledTimes(1) + expect(PACK_INDEX_MAINTENANCE_COOLDOWN_MS).toBeLessThan(REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + }) + + it('keeps an index-only arm until its ref cooldown ends without needing another fetch', async () => { + const { maintenance, target, resolveRefsDirectory, maintainPackIndex } = fixture() + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + await advance(maintenance, PACK_INDEX_MAINTENANCE_COOLDOWN_MS) + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + expect(maintainPackIndex).toHaveBeenCalledTimes(2) + expect(resolveRefsDirectory).toHaveBeenCalledTimes(1) + await advance(maintenance, REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + expect(resolveRefsDirectory).toHaveBeenCalledTimes(2) + }) + + it('retries a failed index sooner without re-probing healthy refs', async () => { + const { maintenance, target, maintainPackIndex, resolveRefsDirectory } = fixture() + target.maintainPackIndex = vi.fn(async () => 'failed' as const) + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + maintenance.arm(target) + await advance(maintenance, PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS) + expect(target.maintainPackIndex).toHaveBeenCalledTimes(2) + expect(resolveRefsDirectory).toHaveBeenCalledTimes(1) + expect(maintainPackIndex).not.toHaveBeenCalled() + }) + + it('honours idle admission again when a preserved cooldown arm becomes eligible', async () => { + const { maintenance, target, maintainPackIndex } = fixture() + let busy = false + target.isBusy = () => busy + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + maintenance.arm(target) + busy = true + await advance(maintenance, PACK_INDEX_MAINTENANCE_COOLDOWN_MS) + expect(maintainPackIndex).toHaveBeenCalledTimes(1) + busy = false + await advance(maintenance, QUIET_MS * 2) + expect(maintainPackIndex).toHaveBeenCalledTimes(2) + }) + + it('preserves protected user indexes for the longer clean cooldown', async () => { + const { maintenance, target } = fixture() + target.maintainPackIndex = vi.fn(async () => 'protected' as const) + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + maintenance.arm(target) + await advance(maintenance, PACK_INDEX_MAINTENANCE_COOLDOWN_MS) + expect(target.maintainPackIndex).toHaveBeenCalledTimes(1) + await advance(maintenance, REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + expect(target.maintainPackIndex).toHaveBeenCalledTimes(2) + }) + + it('lets a create pause return while an admitted index writer is still running', async () => { + const { maintenance, target, packRefs } = fixture() + let started = () => {} + const writerStarted = new Promise((resolve) => { + started = resolve + }) + let finish = () => {} + const writer = new Promise<'written'>((resolve) => { + finish = () => resolve('written') + }) + target.maintainPackIndex = async () => { + started() + return writer + } + maintenance.arm(target) + await vi.advanceTimersByTimeAsync(QUIET_MS) + await writerStarted + const release = await maintenance.pause('worktree create') + expect(packRefs).not.toHaveBeenCalled() + finish() + await maintenance.whenAttemptSettled() + expect(packRefs).not.toHaveBeenCalled() + release() + }) +}) diff --git a/src/shared/repo-ref-maintenance-policy.ts b/src/shared/repo-ref-maintenance-policy.ts index 7dc8edd1de3..763bfb1d0dc 100644 --- a/src/shared/repo-ref-maintenance-policy.ts +++ b/src/shared/repo-ref-maintenance-policy.ts @@ -1,3 +1,5 @@ +import type { PackIndexMaintenanceOutcome } from './repo-pack-index-maintenance-policy' + /** * Idle-time loose-ref packing for repositories Orca itself degrades. * @@ -101,6 +103,7 @@ export type RefMaintenanceOutcome = | 'locked' | 'timed_out' | 'failed' + | 'index_only' /** Structurally satisfied by the tracer's `ActiveSpan`. */ export type RefMaintenanceSpan = { @@ -116,6 +119,12 @@ export type RepoRefMaintenanceTarget = { isOptedOut?(signal: AbortSignal): Promise /** True while work on *this repo* is in flight -- a fetch, a create, a removal. */ isBusy?(): boolean + /** Repair object lookup metadata before counting refs, under the same idle admission. */ + maintainPackIndex?( + signal: AbortSignal, + span: RefMaintenanceSpan, + canWrite: () => boolean + ): Promise /** * Runs `pack-refs` to completion. Deliberately takes no abort signal: killing * a pack is measurably worse than waiting for it (see `PACKED_REFS_LOCK_*`). diff --git a/src/shared/repo-ref-maintenance.test.ts b/src/shared/repo-ref-maintenance.test.ts index f59b894748f..c9940d13dd3 100644 --- a/src/shared/repo-ref-maintenance.test.ts +++ b/src/shared/repo-ref-maintenance.test.ts @@ -203,6 +203,52 @@ describe('RepoRefMaintenance gating', () => { }) }) + it('maintains the pack index even when the loose-ref backlog is small', async () => { + const refs = await refsDirectoryWith(1) + const { maintenance, packRefs } = createHarness() + const maintainPackIndex = vi.fn(async () => {}) + maintenance.arm(target('local::/fragmented/.git', refs, packRefs, { maintainPackIndex })) + await elapseQuietPeriod(maintenance) + expect(maintainPackIndex).toHaveBeenCalledTimes(1) + expect(packRefs).not.toHaveBeenCalled() + }) + + it('rechecks activity after config probes before starting object maintenance', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + let busy = false + const { maintenance, packRefs } = createHarness({ isBusy: () => busy }) + const maintainPackIndex = vi.fn(async () => {}) + maintenance.arm( + target('local::/busy-after-probe/.git', refs, packRefs, { + isOptedOut: async () => { + busy = true + return false + }, + maintainPackIndex + }) + ) + await elapseQuietPeriod(maintenance) + expect(maintainPackIndex).not.toHaveBeenCalled() + expect(packRefs).not.toHaveBeenCalled() + maintenance.dispose() + }) + + it('rechecks activity after object maintenance before packing refs', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + let busy = false + const { maintenance, packRefs } = createHarness({ isBusy: () => busy }) + maintenance.arm( + target('local::/busy-after-index/.git', refs, packRefs, { + maintainPackIndex: async () => { + busy = true + } + }) + ) + await elapseQuietPeriod(maintenance) + expect(packRefs).not.toHaveBeenCalled() + maintenance.dispose() + }) + it('does not run while the app is busy', async () => { const refs = await refsDirectoryWith(THRESHOLD + 2) let busy = true @@ -232,12 +278,17 @@ describe('RepoRefMaintenance gating', () => { const refs = await refsDirectoryWith(THRESHOLD + 2) const { maintenance, spans, packRefs } = createHarness() + const maintainPackIndex = vi.fn(async () => {}) maintenance.arm( - target('local::/opted-out/.git', refs, packRefs, { isOptedOut: async () => true }) + target('local::/opted-out/.git', refs, packRefs, { + isOptedOut: async () => true, + maintainPackIndex + }) ) await elapseQuietPeriod(maintenance) expect(packRefs).not.toHaveBeenCalled() + expect(maintainPackIndex).not.toHaveBeenCalled() expect(attributesOf(spans[0])['repo.maintenance_outcome']).toBe('opted_out') }) @@ -286,6 +337,37 @@ describe('RepoRefMaintenance gating', () => { }) describe('RepoRefMaintenance single-flight and backoff', () => { + it('serializes pack-index writes across execution hosts without merging their identities', async () => { + const refs = await refsDirectoryWith(1) + const { maintenance, packRefs } = createHarness() + const releases: (() => void)[] = [] + const started: string[] = [] + let concurrent = 0 + let peak = 0 + const indexTarget = (key: string) => + target(key, refs, packRefs, { + maintainPackIndex: async () => { + started.push(key) + concurrent += 1 + peak = Math.max(peak, concurrent) + await new Promise((resolve) => releases.push(resolve)) + concurrent -= 1 + } + }) + maintenance.arm(indexTarget('local::/repo/.git')) + maintenance.arm(indexTarget('wsl:Ubuntu::/repo/.git')) + await vi.advanceTimersByTimeAsync(QUIET_MS) + await until(() => concurrent === 1, 'the first index write') + releases.shift()?.() + await maintenance.whenAttemptSettled() + await untilWithTimers(() => started.length === 2, 'the second execution host') + releases.shift()?.() + await maintenance.whenAttemptSettled() + expect(peak).toBe(1) + expect(started).toEqual(['local::/repo/.git', 'wsl:Ubuntu::/repo/.git']) + expect(packRefs).not.toHaveBeenCalled() + }) + it('runs one repository at a time', async () => { const refs = await refsDirectoryWith(THRESHOLD + 2) let concurrent = 0 diff --git a/src/shared/repo-ref-maintenance.ts b/src/shared/repo-ref-maintenance.ts index 97e228fab52..bc4cf3ed9d8 100644 --- a/src/shared/repo-ref-maintenance.ts +++ b/src/shared/repo-ref-maintenance.ts @@ -1,5 +1,6 @@ import { countLooseRefs } from './loose-ref-count' import { PackedRefsLockGate } from './packed-refs-lock-gate' +import { RepoMaintenanceSchedule } from './repo-maintenance-schedule' import { LOOSE_REF_PACK_THRESHOLD, REF_MAINTENANCE_ATTEMPT_DEADLINE_MS, @@ -45,7 +46,7 @@ function hitDeadline(signal: AbortSignal): boolean { export class RepoRefMaintenance { private readonly tracked = new Map() - private readonly cooldownUntil = new Map() + private readonly phases: RepoMaintenanceSchedule private readonly now: () => number private readonly isAppBusy: () => boolean private readonly observe: NonNullable @@ -67,6 +68,7 @@ export class RepoRefMaintenance { constructor(options: RepoRefMaintenanceOptions = {}) { this.now = options.now ?? Date.now + this.phases = new RepoMaintenanceSchedule(this.now) this.isAppBusy = options.isBusy ?? (() => false) this.observe = options.observe ?? ((attempt) => attempt(noopSpan)) this.quietPeriodMs = options.quietPeriodMs ?? REF_MAINTENANCE_QUIET_PERIOD_MS @@ -167,7 +169,7 @@ export class RepoRefMaintenance { } } this.tracked.clear() - this.cooldownUntil.clear() + this.phases.clear() } private isBusy(tracked: TrackedRepo): boolean { @@ -227,8 +229,12 @@ export class RepoRefMaintenance { return } this.tracked.delete(key) - const cooldownUntil = this.cooldownUntil.get(key) - if (cooldownUntil !== undefined && this.now() < cooldownUntil) { + const refDueAt = this.phases.refDueAt(key) + const indexDueAt = tracked.target.maintainPackIndex ? this.phases.indexDueAt(key) : refDueAt + const nextDueAt = Math.min(refDueAt, indexDueAt) + if (this.now() < nextDueAt) { + this.tracked.set(key, tracked) + this.schedule(key, tracked, nextDueAt - this.now()) return } if (this.inFlight !== null) { @@ -266,19 +272,53 @@ export class RepoRefMaintenance { signal: AbortSignal ): Promise { span.setAttribute('repo.maintenance_key', key) - // Every await below carries the signal, so a caller waiting in `pause()` is - // never stuck behind a probe that has already been told to stop. + // Probes are cancellable; admitted index and ref writers finish before releasing the slot. if (await tracked.target.isOptedOut?.(signal)) { - this.settle(key, span, 'opted_out', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + this.phases.postponeIndex(key, REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'opted_out', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) return } if (signal.aborted) { this.yieldTo(key, tracked, span, signal) return } + if (this.suspensions > 0 || this.isBusy(tracked)) { + span.setAttribute('repo.maintenance_outcome', 'deferred' satisfies RefMaintenanceOutcome) + this.defer(key, tracked, true) + return + } + const indexOutcome = await this.phases.maintain( + tracked.target, + signal, + span, + () => !signal.aborted && this.suspensions === 0 && !this.isBusy(tracked) + ) + if (indexOutcome === 'deferred') { + span.setAttribute('repo.maintenance_outcome', 'deferred' satisfies RefMaintenanceOutcome) + this.defer(key, tracked, true) + return + } + if (signal.aborted) { + this.yieldTo(key, tracked, span, signal) + return + } + if (this.suspensions > 0 || this.isBusy(tracked)) { + span.setAttribute('repo.maintenance_outcome', 'deferred' satisfies RefMaintenanceOutcome) + this.defer(key, tracked, true) + return + } + if (this.now() < this.phases.refDueAt(key)) { + span.setAttribute('repo.maintenance_outcome', 'index_only' satisfies RefMaintenanceOutcome) + // Keep the arm for its ref phase; the write that armed it may have left loose refs. + if (!this.disposed && !this.tracked.has(key)) { + this.tracked.set(key, tracked) + this.schedule(key, tracked, this.phases.refDueAt(key) - this.now()) + } + return + } const refsDirectory = await tracked.target.resolveRefsDirectory(signal) if (!refsDirectory) { - this.settle(key, span, 'unresolved', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'unresolved', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) return } const budget = this.looseRefThreshold + 1 @@ -291,7 +331,7 @@ export class RepoRefMaintenance { span.setAttribute('git.loose_ref_threshold', this.looseRefThreshold) // A saturated walk stopped early, so `count` is a floor -- never read it as "clean". if (!before.saturated && before.count < this.looseRefThreshold) { - this.settle(key, span, 'below_threshold', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'below_threshold', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) return } // The quiet window can close while the probe walks; re-check before spending a git slot. @@ -309,7 +349,7 @@ export class RepoRefMaintenance { } catch (error) { span.setAttribute('repo.maintenance_error', String(error)) if (error instanceof RefMaintenanceRepoLocked) { - this.settle(key, span, 'locked', REF_MAINTENANCE_LOCKED_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'locked', REF_MAINTENANCE_LOCKED_COOLDOWN_MS) return } partial = true @@ -324,11 +364,11 @@ export class RepoRefMaintenance { const after = await countLooseRefs(refsDirectory, budget, signal) span.setAttribute('git.loose_ref_count_after', after.count) if (partial && (after.saturated || after.count >= this.looseRefThreshold)) { - this.settle(key, span, 'failed', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'failed', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) return } span.setAttribute('git.pack_refs_partial', partial) - this.settle(key, span, 'packed', REF_MAINTENANCE_PACKED_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'packed', REF_MAINTENANCE_PACKED_COOLDOWN_MS) } /** Record an aborted attempt: retry soon if Orca yielded, back off if it stalled. */ @@ -339,28 +379,10 @@ export class RepoRefMaintenance { signal: AbortSignal ): void { if (hitDeadline(signal)) { - this.settle(key, span, 'timed_out', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'timed_out', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) return } span.setAttribute('repo.maintenance_outcome', 'interrupted' satisfies RefMaintenanceOutcome) this.defer(key, tracked, false) } - - private settle( - key: string, - span: RefMaintenanceSpan, - outcome: RefMaintenanceOutcome, - cooldownMs: number - ): void { - span.setAttribute('repo.maintenance_outcome', outcome) - // Re-insert so Map order stays newest-last and the eviction below drops the oldest. - this.cooldownUntil.delete(key) - this.cooldownUntil.set(key, this.now() + cooldownMs) - if (this.cooldownUntil.size > MAX_TRACKED_REPOS * 4) { - const oldest = this.cooldownUntil.keys().next() - if (!oldest.done) { - this.cooldownUntil.delete(oldest.value) - } - } - } } From b666d0711750ba8d281e3490ae49a03458c78d79 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:38:52 -0700 Subject: [PATCH 10/26] test: update worktree setup and enforce cleanup results (#24552) --- .../e2e/activity-agent-pane-isolation.spec.ts | 3 ++- .../e2e/golden-worktree-create-switch.spec.ts | 9 ++------- tests/e2e/helpers/dead-terminal.ts | 18 ++++++++++++++---- .../worktree-lineage-agent-expansion.spec.ts | 2 +- tests/e2e/worktree-lineage-state.ts | 2 +- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/tests/e2e/activity-agent-pane-isolation.spec.ts b/tests/e2e/activity-agent-pane-isolation.spec.ts index 050f02e93cc..eb78ef70985 100644 --- a/tests/e2e/activity-agent-pane-isolation.spec.ts +++ b/tests/e2e/activity-agent-pane-isolation.spec.ts @@ -116,7 +116,7 @@ async function enableInlineAgentCards(page: Page): Promise { const state = store.getState() if (!state.worktreeCardProperties.includes('inline-agents')) { - state.toggleWorktreeCardProperty('inline-agents') + state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents']) } state.closeActivityPage() }) @@ -272,6 +272,7 @@ test.describe('Activity Agent Pane Isolation', () => { const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) const [first, second] = await seedActivityThreadsForSplitPanes(orcaPage, snapshot) + await orcaPage.evaluate(() => window.__store?.getState().setWorktreeCardProperties([])) await enableInlineAgentCards(orcaPage) await clickWorkspaceCardAgentRow(orcaPage, first.prompt) diff --git a/tests/e2e/golden-worktree-create-switch.spec.ts b/tests/e2e/golden-worktree-create-switch.spec.ts index dd15ca3115c..55eacbe8903 100644 --- a/tests/e2e/golden-worktree-create-switch.spec.ts +++ b/tests/e2e/golden-worktree-create-switch.spec.ts @@ -1,3 +1,4 @@ +import { removeWorktreeViaStore } from './helpers/dead-terminal' import { openSidebarWorkspaceComposer } from './helpers/sidebar-project-dialog' import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' @@ -26,12 +27,6 @@ async function createWorkspace(page: Page, name: string): Promise { await expect(dialog).toBeHidden({ timeout: 20_000 }) } -async function removeCreatedWorktree(page: Page, worktreeId: string): Promise { - await page.evaluate(async (id) => { - await window.__store?.getState().removeWorktree(id, true) - }, worktreeId) -} - test('creates a worktree, keeps its terminal isolated, and switches back @golden', async ({ orcaPage }) => { @@ -86,7 +81,7 @@ test('creates a worktree, keeps its terminal isolated, and switches back @golden .click() .catch(() => undefined) } - await removeCreatedWorktree(orcaPage, childWorktreeId).catch(() => undefined) + await removeWorktreeViaStore(orcaPage, childWorktreeId) } } }) diff --git a/tests/e2e/helpers/dead-terminal.ts b/tests/e2e/helpers/dead-terminal.ts index 71814f82b7e..2256124b77d 100644 --- a/tests/e2e/helpers/dead-terminal.ts +++ b/tests/e2e/helpers/dead-terminal.ts @@ -125,10 +125,20 @@ export async function createAndActivateWorktreeWithSetup( export async function removeWorktreeViaStore(page: TestPage, worktreeId: string): Promise { await page.evaluate(async (id) => { - try { - await window.__store?.getState().removeWorktree(id, true) - } catch { - /* best-effort */ + const state = window.__store?.getState() + if (!state) { + throw new Error('Worktree cleanup requires the app store') + } + const worktree = state.getKnownWorktreeById(id) + if (!worktree) { + return + } + const result = await state.removeWorktree( + { id, executionHostId: worktree.hostId ?? null }, + true + ) + if (!result.ok) { + throw new Error(`Worktree cleanup failed: ${result.error}`) } }, worktreeId) } diff --git a/tests/e2e/worktree-lineage-agent-expansion.spec.ts b/tests/e2e/worktree-lineage-agent-expansion.spec.ts index 6662d7eafe0..d8388bb300e 100644 --- a/tests/e2e/worktree-lineage-agent-expansion.spec.ts +++ b/tests/e2e/worktree-lineage-agent-expansion.spec.ts @@ -29,7 +29,7 @@ async function seedTwoParentAgents(page: Page, worktreeId: string): Promise Date: Thu, 1 Oct 2026 23:44:39 -0700 Subject: [PATCH 11/26] test: restore delayed PTY writes in large-paste coverage (#24556) --- tests/e2e/helpers/terminal-pty-write-spy.ts | 4 ++-- .../terminal-pty-write-spy.unit.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts diff --git a/tests/e2e/helpers/terminal-pty-write-spy.ts b/tests/e2e/helpers/terminal-pty-write-spy.ts index 5cb88e6868d..68295c08e61 100644 --- a/tests/e2e/helpers/terminal-pty-write-spy.ts +++ b/tests/e2e/helpers/terminal-pty-write-spy.ts @@ -71,10 +71,10 @@ export async function readTerminalPtyWriteEntries( } export async function setTerminalPtyWriteDelay( - app: ElectronApplication, + app: Pick, delayMs: number ): Promise { - await app.evaluate((nextDelayMs) => { + await app.evaluate((_electron, nextDelayMs) => { const global = globalThis as unknown as { __terminalPtyWriteDelayMs?: number } global.__terminalPtyWriteDelayMs = Math.max(0, nextDelayMs) }, delayMs) diff --git a/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts new file mode 100644 index 00000000000..5c922608008 --- /dev/null +++ b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts @@ -0,0 +1,18 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { setTerminalPtyWriteDelay } from './terminal-pty-write-spy' + +afterEach(() => vi.unstubAllGlobals()) + +it('sets and clears paste backpressure using the main-process evaluate argument', async () => { + vi.stubGlobal('__terminalPtyWriteDelayMs', 0) + const evaluate = vi.fn() + evaluate.mockImplementation( + (callback: (electron: object, argument: number) => void, delay: number) => callback({}, delay) + ) + const app = { evaluate } + + await setTerminalPtyWriteDelay(app, 35) + expect(Reflect.get(globalThis, '__terminalPtyWriteDelayMs')).toBe(35) + await setTerminalPtyWriteDelay(app, 0) + expect(Reflect.get(globalThis, '__terminalPtyWriteDelayMs')).toBe(0) +}) From e2c5414f76c257cc542a6365407b69e248c3aa24 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:49:14 -0700 Subject: [PATCH 12/26] fix(native-chat): an older Orca keeps a chat with a newer row kind read-only instead of deleting the rest of its history (#24477) * fix(native-chat): an older Orca skips and keeps a journal row of a kind it does not know * test(native-chat): a newer build's journal row kind survives reads, writes, rewinds and reopens * fix(native-chat): an older Orca keeps an unknown journal row kind read-only unless its writer declared it skippable A row of a kind this build does not know, in a well-formed envelope, now latches the chat read-only with every row kept, the same way a newer row version does. It is read past only when its writer declared `ifUnknown` on the row: `skip` (a rewind drops it) or `carry` (a rewind carries it after the rebuilt history, epoch, seq and fence restamped). Every existing kind changes queue or turn state, so skipping by default would let an older build write from a wrong fold. - journal-row-kind-compatibility.ts: each kind states how older builds read it, typed over every row kind, so a new kind cannot be added without a declaration. - Rewind restates the Resume and Stop as before, then carries `carry` rows in source order; the restatement goes back to { lifted, liveStop }. - Replay treats a row whose body names another sequence than its stored key as malformed at the key, so the next write never collides with it; catch-up reads stop there too. * refactor(native-chat): drop the writer opt-in; an unknown journal row kind only latches read-only An older Orca now treats a row of a kind it does not know exactly like a row from a newer schema version: every row stays on disk and the chat opens read-only until an update. The writer-declared skip/carry opt-in, its in-memory placeholder, the carry through rewinds and the per-kind registry are removed: no current or planned kind could use them, and they can come with the first kind that may safely be read past. Kept: an unknown kind needs the envelope every row keeps (epoch, sequence, fence, timestamp), else it is damage as before; a row whose body names another sequence than its stored key is malformed at the key; the epoch row's validator names its kind. The schema header states the rule for adding a kind: keep the envelope, and either ship the reader first or bump `v`. * refactor(native-chat): derive the journal's known row kinds from the row union Each kind's own-field check now lives in one table keyed by every kind JournalRow holds, and the set of kinds this build knows is derived from that table. A kind added to the union without a check fails to compile, rather than latching this build's own chats read-only as a newer build's kind. A test reads one valid row of every kind. --- .../agent-session-journal/journal-open.ts | 15 +- .../journal-row-schema.ts | 144 ++++----- .../journal-unknown-row-kind.test.ts | 276 ++++++++++++++++++ src/shared/agent-session-journal-types.ts | 5 +- ...-session-stop-event-downgrade.unit.test.ts | 67 ++++- 5 files changed, 427 insertions(+), 80 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index 8f947272dbc..ff811983a25 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -26,12 +26,14 @@ const FIRST_JOURNAL_SEQUENCE = 1 export type JournalLoad = { state: JournalReducerState - /** A row from a future schema was met: no writes, no deletion. */ + /** A row from a future schema, or of a kind this build does not know, was met: no writes, no + * deletion. */ readOnly: boolean /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ corrupt: boolean - /** Rows skipped because their body failed to parse (future-version rows are - * `readOnly`, never counted here). The store discloses these in the timeline. */ + /** Rows dropped because they failed to parse or name another sequence than their key (an + * unreadable row latches `readOnly`, never counted here). The store discloses these in the + * timeline. */ malformedRows: number /** Directory-internal: the first sequence of an unusable suffix. The store * deletes from here before it accepts a write; a probe leaves it alone. */ @@ -92,10 +94,11 @@ export function startJournalRowFold(input: JournalRowFoldInput): { const add = (entry: { seq: number; rowJson: string }): boolean => { const parsed = parseJournalRow(entry.rowJson) - if (!parsed.ok) { + // A body naming another sequence than its key is malformed there: writes number past the key. + if (!parsed.ok || parsed.row.seq !== entry.seq) { truncateFrom = entry.seq - latched = parsed.unreadable - malformedRows = parsed.unreadable ? 0 : 1 + latched = !parsed.ok && parsed.unreadable + malformedRows = latched ? 0 : 1 return false } const row = parsed.row diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e65d5919b39..9276b1a8c05 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -4,6 +4,14 @@ // in-place rewrite. A row whose version this build does not understand is // UNREADABLE, not skippable: the caller must degrade to read-only rather than // render a partial timeline or compact past a row it cannot interpret. +// +// A row whose KIND this build does not know is UNREADABLE the same way, and kept +// on disk, when it has the envelope every row keeps: a non-empty `epoch`, an +// integer `seq` >= 1, an integer `fence` and a numeric `ts`. So a new kind keeps +// that envelope; changing the envelope is a `v` bump. Builds from before this +// rule delete the journal from an unknown kind, so a new kind either ships its +// reader first and is written only once no supported build lacks that reader, +// or is written at a bumped `v`. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -79,10 +87,10 @@ export type JournalTombstoneRow = JournalRowBase & { queueResume?: true } -/** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host - * deletes the journal from the first row kind it does not know (`journal-open.ts` then - * `journal-store-open.ts`) but ignores an unknown key; a row kind of its own once released hosts - * skip unknown kinds instead. */ +/** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, + * where a host from before the header's rule deletes the journal from a kind it does not know. A + * kind of its own ships its reader first and is written once no supported build lacks that + * reader, or is written at a bumped `v`: an older host must never fold past a person's Stop. */ export type JournalStopEvent = { /** Persisted: never rename an arm. Only `user-stop` pauses the queue. */ reason: StructuredAgentSessionStopCause @@ -180,25 +188,17 @@ export type JournalRowParse = | { ok: true; row: JournalRow } /** Malformed JSON or a shape this build rejects outright. */ | { ok: false; unreadable: false } - /** A future schema version. The host must not write or compact this journal. */ + /** A future schema version, or a kind this build does not know. The host must not write or + * compact this journal. */ | { ok: false; unreadable: true } -const ROW_KINDS = new Set([ - 'epoch', - 'item', - 'tombstone', - 'submission', - 'dispatch', - 'lifecycle-batch' -]) - export function serializeJournalRow(row: JournalRow): string { return JSON.stringify(row) } /** - * Parse one persisted line. Older versions are upcast; newer versions are - * reported as unreadable so the caller fails closed. + * Parse one persisted line. Older versions are upcast; newer versions and newer + * kinds are reported as unreadable so the caller fails closed. */ export function parseJournalRow(line: string): JournalRowParse { let parsed: unknown @@ -230,7 +230,13 @@ export function parseJournalRow(line: string): JournalRowParse { } } dropUnusableContextUsage(upcast) - return isJournalRow(upcast) ? { ok: true, row: upcast } : { ok: false, unreadable: false } + if (isJournalRow(upcast)) { + return { ok: true, row: upcast } + } + // A newer build's kind is placed by the envelope every row keeps; one without it is damage. + const { kind } = upcast + const unknownKind = typeof kind === 'string' && kind !== '' && !KNOWN_ROW_KINDS.has(kind) + return { ok: false, unreadable: unknownKind && hasJournalRowEnvelope(upcast) } } /** Linkage ids this build cannot trust, removed from a row it still keeps. @@ -317,60 +323,56 @@ function isPlainObject(value: unknown): value is Record { * schema — their nested shapes are dereferenced unguarded all the way to the * rendered surface, so a JSON-valid corruption must fail here, not there. */ function isJournalRow(record: Record): record is JournalRow { - if (typeof record.kind !== 'string' || !ROW_KINDS.has(record.kind)) { - return false - } - if ( - typeof record.epoch !== 'string' || - !record.epoch || - !Number.isInteger(record.seq) || - (record.seq as number) < 1 || - !Number.isInteger(record.fence) || - typeof record.ts !== 'number' - ) { - return false - } - if (record.kind === 'item') { - return ( - typeof record.itemId === 'string' && - Number.isInteger(record.revision) && - isAdmissibleAgentJournalItemBody(record.body) - ) - } - if (record.kind === 'tombstone') { - return typeof record.itemId === 'string' && Number.isInteger(record.revision) - } - if (record.kind === 'submission') { - return ( - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.payloadFingerprint === 'string' && - isPlainObject(record.providerHandle) && - isAdmissibleAgentJournalMessageBody(record.body) - ) - } - if (record.kind === 'dispatch') { - return ( - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.state === 'string' && - record.state.length > 0 && - (record.providerItemId === null || typeof record.providerItemId === 'string') && - (record.reason === null || typeof record.reason === 'string') - ) - } - if (record.kind === 'lifecycle-batch') { - return ( - typeof record.settlementId === 'string' && - record.settlementId.length > 0 && - Array.isArray(record.mutations) && - record.mutations.length > 0 && - record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && - Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && - record.mutations.every(isLifecycleMutation) - ) - } - return typeof record.reason === 'string' && isPlainObject(record.providerHandle) + const fieldCheck = typeof record.kind === 'string' ? KNOWN_ROW_KINDS.get(record.kind) : undefined + return fieldCheck !== undefined && hasJournalRowEnvelope(record) && fieldCheck(record) +} + +/** Each kind's own fields, keyed by every kind the union holds: a kind without a check here fails + * to compile, never reads as a newer build's kind. */ +const ROW_FIELD_CHECK_BY_KIND: Record< + JournalRow['kind'], + (record: Record) => boolean +> = { + epoch: (record) => typeof record.reason === 'string' && isPlainObject(record.providerHandle), + item: (record) => + typeof record.itemId === 'string' && + Number.isInteger(record.revision) && + isAdmissibleAgentJournalItemBody(record.body), + tombstone: (record) => typeof record.itemId === 'string' && Number.isInteger(record.revision), + submission: (record) => + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle) && + isAdmissibleAgentJournalMessageBody(record.body), + dispatch: (record) => + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.state === 'string' && + record.state.length > 0 && + (record.providerItemId === null || typeof record.providerItemId === 'string') && + (record.reason === null || typeof record.reason === 'string'), + 'lifecycle-batch': (record) => + typeof record.settlementId === 'string' && + record.settlementId.length > 0 && + Array.isArray(record.mutations) && + record.mutations.length > 0 && + record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && + Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && + record.mutations.every(isLifecycleMutation) +} +const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_FIELD_CHECK_BY_KIND)) + +/** The fields every row keeps whatever its kind: its epoch, its place in it, its writer, its time. */ +function hasJournalRowEnvelope(record: Record): boolean { + return ( + typeof record.epoch === 'string' && + record.epoch !== '' && + Number.isInteger(record.seq) && + Number(record.seq) >= 1 && + Number.isInteger(record.fence) && + typeof record.ts === 'number' + ) } function isLifecycleMutation(value: unknown): value is JournalLifecycleMutation { diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts new file mode 100644 index 00000000000..794ca52ab7d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -0,0 +1,276 @@ +// A newer build's row kind is never repaired away: like a newer row version, it latches this build +// read-only with every row kept, so the newer build still reads the whole chat after an upgrade. +// A row whose stored sequence and body disagree is damage at its stored sequence. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { parseJournalRow, type JournalRow } from './journal-row-schema' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-kind', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-kind-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function itemIds(journal: { snapshot: () => { items: { itemId: string }[] } }): string[] { + return journal.snapshot().items.map((entry) => entry.itemId) +} + +/** What a newer build would write: a kind this build does not know, in the envelope every row keeps. */ +function newerRow(epoch: string, seq: number, extra: Record = {}) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch, + seq, + fence: 1, + ts: 5_000 + seq, + payload: { said: 'by a newer build', seq }, + ...extra + } +} + +function open() { + return journals.open({ identity: IDENTITY, stateDirectory: root }) +} + +/** Closes the chat, applies `edit` to its stored rows as a newer build or a bad write would, and + * reopens it as a restarted host would. */ +async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { + await journals.closeAll() + const { db } = openTestJournalHostDatabase(root) + edit((seq, json) => { + db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( + IDENTITY.sessionId, + seq + ) + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, json, 5_000 + seq) + }) + closeTestJournalHostDatabase(root) + return open() +} + +function stored(): { seq: number; rowJson: string }[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( + (row) => ({ seq: row.seq, rowJson: row.rowJson }) + ) +} + +function storedKinds(): [number, unknown][] { + return stored().map((row) => [row.seq, JSON.parse(row.rowJson).kind]) +} + +/** A journal of its anchor and one item, closed: the next row lands at sequence 3. */ +async function journalWithOneItem(): Promise { + const first = await open() + await first.appendItem(item(0), { kind: 'status', text: 'before' }, SCOPE) + return first.epoch +} + +/** One valid row of every kind this build writes; a kind added to the union without one here + * fails to compile. */ +function rowOfEveryKind(): { [Kind in JournalRow['kind']]: Extract } { + const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1 } + const providerHandle = IDENTITY.providerHandle + return { + epoch: { ...base, seq: 1, kind: 'epoch', reason: 'session_created', providerHandle }, + item: { + ...base, + seq: 2, + kind: 'item', + itemId: 'i', + revision: 1, + body: { kind: 'status', text: 'x' } + }, + tombstone: { ...base, seq: 3, kind: 'tombstone', itemId: 'i', revision: 2 }, + submission: { + ...base, + seq: 4, + kind: 'submission', + clientMessageId: 'c', + payloadFingerprint: 'f', + providerHandle, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + }, + dispatch: { + ...base, + seq: 5, + kind: 'dispatch', + clientMessageId: 'c', + state: 'accepted', + providerItemId: null, + reason: null + }, + 'lifecycle-batch': { + ...base, + seq: 6, + kind: 'lifecycle-batch', + settlementId: 's', + mutations: [{ kind: 'tombstone', itemId: 'i', revision: 3 }] + } + } +} + +it("reads a row of every kind this build writes, never as a newer build's kind", () => { + for (const row of Object.values(rowOfEveryKind())) { + expect(parseJournalRow(JSON.stringify(row))).toEqual({ ok: true, row }) + } +}) + +describe('parsing a row of a kind this build does not know', () => { + it('reads one in the envelope every row keeps as unreadable', () => { + expect(parseJournalRow(JSON.stringify(newerRow('epoch-1', 7)))).toEqual({ + ok: false, + unreadable: true + }) + }) + + it.each([ + ['no sequence', { seq: undefined }], + ['a sequence of 0', { seq: 0 }], + ['a string sequence', { seq: '7' }], + ['a fractional fence', { fence: 1.5 }], + ['a string timestamp', { ts: '5007' }], + ['an empty epoch', { epoch: '' }], + ['an empty kind', { kind: '' }], + ['a kind that is not a string', { kind: 7 }] + ])('reads one with %s as malformed', (_name, broken) => { + const parsed = parseJournalRow(JSON.stringify(newerRow('epoch-1', 7, broken))) + expect(parsed).toEqual({ ok: false, unreadable: false }) + }) + + it('reads a known kind that fails its own checks as malformed', () => { + const parsed = parseJournalRow( + JSON.stringify({ ...newerRow('epoch-1', 7), kind: 'item', itemId: 'x', revision: 1 }) + ) + expect(parsed).toEqual({ ok: false, unreadable: false }) + }) + + it('reads a future schema version as unreadable before it looks at the kind', () => { + const parsed = parseJournalRow( + JSON.stringify(newerRow('epoch-1', 7, { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })) + ) + expect(parsed).toEqual({ ok: false, unreadable: true }) + }) +}) + +describe("a journal holding a newer build's row kind", () => { + it('opens read-only with the rows before it, refuses writes, keeps every row, and reads whole once the kind is known', async () => { + const first = await open() + for (const ordinal of [0, 1, 2, 3]) { + await first.appendItem(item(ordinal), { kind: 'status', text: `item ${ordinal}` }, SCOPE) + } + const epoch = first.epoch + await journals.closeAll() + const known = stored().find((row) => row.seq === 3)?.rowJson ?? '' + const newer = JSON.stringify(newerRow(epoch, 3)) + + const journal = await restartAfter((put) => put(3, newer)) + const latched = stored() + expect(journal.isReadOnly).toBe(true) + expect(journal.repair).toEqual({ malformedRows: 0 }) + expect(itemIds(journal)).toEqual(['codex:thread-1:turn-1:0']) + await expect( + journal.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) + ).rejects.toMatchObject({ code: 'journal_read_only' }) + + const reopened = await restartAfter() + expect(reopened.isReadOnly).toBe(true) + expect(stored()).toEqual(latched) + expect(stored().find((row) => row.seq === 3)?.rowJson).toBe(newer) + + // Stand-in for the newer build after an upgrade: the same place holds a kind it reads. + const upgraded = await restartAfter((put) => put(3, known)) + expect(upgraded.isReadOnly).toBe(false) + expect(itemIds(upgraded)).toHaveLength(4) + await upgraded.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) + expect(upgraded.cursor().sequence).toBe(6) + }) + + it('stays read-only with nothing deleted when the row names another sequence than its key', async () => { + const epoch = await journalWithOneItem() + const journal = await restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) + expect(journal.isReadOnly).toBe(true) + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'future-mark'] + ]) + }) + + it('reads a newer kind whose envelope is broken as malformed and drops from it', async () => { + const epoch = await journalWithOneItem() + const journal = await restartAfter((put) => + put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' }))) + ) + expect(journal.isReadOnly).toBe(false) + expect(journal.repair).toEqual({ malformedRows: 1 }) + // 3 is the disclosure the repair appends where the broken row was. + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'item'] + ]) + }) +}) + +describe('a row whose body names another sequence than its stored key', () => { + it.each([ + ['an earlier', 2], + ['a later', 9] + ])( + 'is dropped at its key when it names %s one, and the next write succeeds', + async (_name, seq) => { + const epoch = await journalWithOneItem() + const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } + const journal = await restartAfter((put) => put(3, JSON.stringify(tombstone))) + expect(journal.repair).toEqual({ malformedRows: 1 }) + expect(journal.needsRebuild).toBe(true) + // The valid row at 2 stays; 3 is the disclosure the repair appends. + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'item'] + ]) + + await journal.appendItem(item(1), { kind: 'status', text: 'after' }, SCOPE) + const reopened = await restartAfter() + expect(reopened.cursor().sequence).toBe(4) + expect(itemIds(reopened)).toHaveLength(3) + } + ) +}) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 548b263bbfe..e5f8125a5c0 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -4,8 +4,9 @@ // so no class instances, Maps, or Dates. // // Rows are append-only. `schemaVersion` is upcast at read time and never -// rewritten in place, so a host that cannot read a row refuses to write the -// journal rather than skipping or compacting past it. +// rewritten in place, so a host that cannot read a row (a newer version, or a +// newer kind) refuses to write the journal rather than skipping or compacting +// past it. import type { UnreadAgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureRowWords } from './agent-session-failure-words' diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index af26da9c687..b31c8f008cd 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -4,14 +4,18 @@ import { join } from 'node:path' import { expect, test } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalItemIdentity, type AgentSessionJournalIdentity } from '../../../src/shared/agent-session-journal-types' import Database from '../../../src/main/sqlite/sync-database' import { journalDatabasePath } from '../../../src/main/native-chat/agent-session-journal/journal-host-database' import { + closeTestJournalHostDatabase, createTrackedJournalOpener, - liveTestJournalRows + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' @@ -224,3 +228,64 @@ test("an older build opens this build's journal writable and appends to it; the rmSync(directory, { recursive: true, force: true }) } }, 120_000) + +// Why a Stop's event cannot have a row kind of its own yet: this build keeps a kind it does not know +// and goes read-only, but a build from before that deletes the journal from it. So a Stop kind ships +// its reader first and is written once no supported build lacks that reader, or is written at a +// bumped `v`. Move the baseline to the first release with this rule, and the older build keeps the +// row too. +test("this build keeps a newer build's row kind and goes read-only; a build before it deletes it", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-newer-kind-downgrade-')) + const journals = createTrackedJournalOpener() + const newerKinds = () => storedRows(directory).filter((row) => row.includes('"future-mark"')) + try { + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await journal.appendItem(item(0), { kind: 'status', text: 'before' }, scope) + const at = journal.cursor().sequence + 1 + const newer = JSON.stringify({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch: journal.epoch, + seq: at, + fence: 1, + ts: 2_000, + payload: { said: 'by a newer build' } + }) + await journals.closeAll() + insertTestJournalRowJson( + openTestJournalHostDatabase(directory).db, + IDENTITY.sessionId, + at, + newer + ) + closeTestJournalHostDatabase(directory) + const rowsBefore = storedRows(directory) + + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(reopened.isReadOnly).toBe(true) + expect(reopened.repair).toEqual({ malformedRows: 0 }) + await expect( + reopened.appendItem(item(1), { kind: 'status', text: 'after' }, scope) + ).rejects.toMatchObject({ code: 'journal_read_only' }) + await journals.closeAll() + expect(storedRows(directory)).toEqual(rowsBefore) + expect(newerKinds()).toEqual([newer]) + + const checkout = await materializeReleaseCheckout(WRITABLE_BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() + try { + await older.open({ identity: IDENTITY, stateDirectory: directory }) + } finally { + await older.closeAll() + } + expect(newerKinds()).toEqual([]) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000) From ba9af21d7590ba0406c5c3fb39d92aa233de3627 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:56:49 -0700 Subject: [PATCH 13/26] test: classify terminal driver input with the current PTY contract (#24560) --- tests/e2e/helpers/runtime-types.ts | 2 ++ tests/e2e/helpers/terminal-active-pane.ts | 2 +- tests/e2e/helpers/terminal-input-probes.ts | 2 +- tests/e2e/helpers/terminal-pane-operations.ts | 2 +- tests/e2e/ssh-docker-relay-perf.spec.ts | 13 ++++++++++--- tests/e2e/terminal-output-scheduler.spec.ts | 2 +- 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts index 215e1ce5bcc..0d4b864ee64 100644 --- a/tests/e2e/helpers/runtime-types.ts +++ b/tests/e2e/helpers/runtime-types.ts @@ -1,4 +1,5 @@ import type { AppState } from '../../../src/renderer/src/store/types' +import type { PaneManager } from '../../../src/renderer/src/lib/pane-manager/pane-manager' import type { OpenFile, RightSidebarTab } from '../../../src/renderer/src/store/slices/editor' import type { ManagedPane, @@ -31,6 +32,7 @@ export type PaneManagerLike = { getPanes(limit?: number): ManagedPaneHandle[] splitPane(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPaneHandle | null closePane(paneId: number): void + movePane: PaneManager['movePane'] setActivePane(paneId: number, opts?: { focus?: boolean }): void suspendRendering(): void resumeRendering(): void diff --git a/tests/e2e/helpers/terminal-active-pane.ts b/tests/e2e/helpers/terminal-active-pane.ts index b5987c61f3a..08ca40331c5 100644 --- a/tests/e2e/helpers/terminal-active-pane.ts +++ b/tests/e2e/helpers/terminal-active-pane.ts @@ -126,7 +126,7 @@ export async function discoverActivePtyId(page: Page): Promise { // Echo a numeric probe index, then map it back to the opaque ID in Node. for (const [index, id] of candidateIds.entries()) { for (const input of candidateInputs[index] ?? []) { - window.api.pty.write(String(id), input) + window.api.pty.write(String(id), input, 'driving') } } }, diff --git a/tests/e2e/helpers/terminal-input-probes.ts b/tests/e2e/helpers/terminal-input-probes.ts index 9d6b45457a0..a7b3496551c 100644 --- a/tests/e2e/helpers/terminal-input-probes.ts +++ b/tests/e2e/helpers/terminal-input-probes.ts @@ -247,7 +247,7 @@ export async function mainProbeDirectWrite( const inputs = buildSettledShellProbeInputSequence(`echo ${marker}\r`) await mainRendererEval( electronApp, - `for (const input of ${JSON.stringify(inputs)}) { window.api.pty.write(${JSON.stringify(ptyId)}, input) }` + `for (const input of ${JSON.stringify(inputs)}) { window.api.pty.write(${JSON.stringify(ptyId)}, input, "driving") }` ) } catch { return false diff --git a/tests/e2e/helpers/terminal-pane-operations.ts b/tests/e2e/helpers/terminal-pane-operations.ts index d3d488f35e3..81023dce669 100644 --- a/tests/e2e/helpers/terminal-pane-operations.ts +++ b/tests/e2e/helpers/terminal-pane-operations.ts @@ -55,7 +55,7 @@ export async function moveTerminalPaneByLeafId( export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { await page.evaluate( ({ ptyId, text }) => { - window.api.pty.write(ptyId, text) + window.api.pty.write(ptyId, text, 'driving') }, { ptyId, text } ) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index fe39cfef709..cc7f5efbc38 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { @@ -107,7 +108,10 @@ async function measureRemoteTyping( const char = KEY_LATENCY_SAMPLES[index] const marker = `REMOTE_KEY_${runId}_${index + 1}_${char}` const started = performance.now() - await page.evaluate(({ ptyId, char }) => window.api.pty.write(ptyId, char), { ptyId, char }) + await page.evaluate(({ ptyId, char }) => window.api.pty.write(ptyId, char, 'driving'), { + ptyId, + char + }) await waitForTerminalOutput(page, marker, 10_000, 80_000) latencies.push(performance.now() - started) } @@ -141,7 +145,7 @@ async function readSshPtyAckGate(page: Page): Promise { - await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03'), ptyId) + await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03', 'driving'), ptyId) } test.describe('Docker SSH relay perf', () => { @@ -208,7 +212,10 @@ test.describe('Docker SSH relay perf', () => { ) await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) - await orcaPage.evaluate((ptyId) => window.api.pty.write(ptyId, 'g'), backgroundPtyId) + await orcaPage.evaluate( + (ptyId) => window.api.pty.write(ptyId, 'g', 'driving'), + backgroundPtyId + ) await splitActiveTerminalPane(orcaPage, 'vertical') await focusLastTerminalPane(orcaPage) diff --git a/tests/e2e/terminal-output-scheduler.spec.ts b/tests/e2e/terminal-output-scheduler.spec.ts index dc7b5525883..031658ea0dc 100644 --- a/tests/e2e/terminal-output-scheduler.spec.ts +++ b/tests/e2e/terminal-output-scheduler.spec.ts @@ -159,7 +159,7 @@ async function sendPtyCommands( ): Promise { await page.evaluate((items) => { for (const item of items) { - window.api.pty.write(item.ptyId, `${item.command}\r`) + window.api.pty.write(item.ptyId, `${item.command}\r`, 'driving') } }, commands) } From 3f37fcc423582b023dc6b9d7305a1056b655edbb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:21:38 -0700 Subject: [PATCH 14/26] test: align source-control fixtures with current store contracts (#24571) --- tests/e2e/helpers/pr-comments-sidebar-fixture.ts | 15 +++++++++++---- tests/e2e/helpers/source-control-ai-generation.ts | 11 ++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts index f01661f2de8..446f8b2c6ef 100644 --- a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts +++ b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts @@ -1,6 +1,7 @@ import type { Page } from '@stablyai/playwright-test' import type { PRComment } from '../../../src/shared/github/comment-types' import type { PRInfo } from '../../../src/shared/github/pull-request-types' +import { getDefaultSourceControlAiSettings } from '../../../src/shared/source-control-ai' export type PRCommentsSidebarSeed = { worktreeId: string @@ -46,7 +47,11 @@ export const FIXTURE_COMMENTS: PRComment[] = [ /** Seed an open PR on e2e-secondary with mixed comment triage states for sidebar tests. */ export async function seedPRCommentsSidebarFixture(page: Page): Promise { - return page.evaluate(async (fixtureComments: PRComment[]) => { + const seed = { + fixtureComments: FIXTURE_COMMENTS, + sourceControlAiDefaults: getDefaultSourceControlAiSettings() + } + return page.evaluate(async ({ fixtureComments, sourceControlAiDefaults }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -117,12 +122,13 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise { + fetchPRForBranch: async (_repoPath: string, targetBranch: string) => { if (targetBranch !== branch) { return null } @@ -137,12 +143,13 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise [], fetchPRComments: async () => comments, setPRCommentReaction: async () => true, - fetchUpstreamStatus: async () => undefined, + fetchUpstreamStatus: async (worktreeId) => + store.getState().remoteStatusesByWorktree[worktreeId] ?? null, setUpstreamStatus: () => undefined })) window.localStorage.setItem('orca:pr-comment-presentation', 'cards') return { worktreeId: worktree.id, branch, prNumber } - }, FIXTURE_COMMENTS) + }, seed) } diff --git a/tests/e2e/helpers/source-control-ai-generation.ts b/tests/e2e/helpers/source-control-ai-generation.ts index 3a488bc58db..b290cdce1e9 100644 --- a/tests/e2e/helpers/source-control-ai-generation.ts +++ b/tests/e2e/helpers/source-control-ai-generation.ts @@ -134,7 +134,8 @@ export async function seedCreatePrComposer(page: Page): Promise<{ // Ignore provider work queued before this generation-only fixture was installed. getEffectiveGitHubPRRefreshState: () => undefined, prRefreshStates: {}, - fetchUpstreamStatus: async () => undefined, + fetchUpstreamStatus: async (worktreeId) => + store.getState().remoteStatusesByWorktree[worktreeId] ?? null, setUpstreamStatus: () => undefined })) @@ -222,8 +223,8 @@ export async function seedCommitMessageComposer(page: Page): Promise<{ status: 'ready' as const } }, - gitBranchCompareEntriesByWorktree: { - ...current.gitBranchCompareEntriesByWorktree, + gitBranchChangesByWorktree: { + ...current.gitBranchChangesByWorktree, [primaryWorktree.id]: [] } })) @@ -290,8 +291,8 @@ export async function seedCleanBranchEmptyState( status: 'ready' as const } }, - gitBranchCompareEntriesByWorktree: { - ...current.gitBranchCompareEntriesByWorktree, + gitBranchChangesByWorktree: { + ...current.gitBranchChangesByWorktree, [primaryWorktree.id]: [] } })) From 306b4578aaa5c55f27add2a75607fd5eb347eca2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:28:30 -0700 Subject: [PATCH 15/26] Enable Option shortcuts for ABC keyboards in Auto mode (#24528) * Clarify Option shortcut settings and cover punctuation input * Enable Auto Option shortcuts on ABC keyboards safely --- src/main/ipc/app.test.ts | 145 +++---- src/main/ipc/app.ts | 64 +-- .../settings/TerminalMacKeyboardSection.tsx | 59 ++- .../terminal-option-punctuation.test.ts | 179 +++++++++ src/renderer/src/i18n/locales/en.json | 6 + src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/fr.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 8 +- .../keyboard-layout/detect-option-as-alt.ts | 11 +- .../keyboard-layout/input-source-id.test.ts | 26 +- .../lib/keyboard-layout/input-source-id.ts | 16 +- .../option-as-alt-probe.test.ts | 247 +++++++++--- .../keyboard-layout/option-as-alt-probe.ts | 31 +- .../terminal-option-composed-ascii.spec.ts | 375 +++++++++++------- tests/e2e/terminal-option-key-input.ts | 210 ++++++++++ 17 files changed, 1033 insertions(+), 368 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-option-punctuation.test.ts create mode 100644 tests/e2e/terminal-option-key-input.ts diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e52668e1ce9..aba55bee6e5 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -275,44 +275,53 @@ describe('registerAppHandlers', () => { expect(appExitMock).not.toHaveBeenCalled() }) - it('returns the selected macOS input mode before the keyboard layout fallback', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - spawnMock.mockImplementation(() => - createFakeSpawnChild({ - stdout: JSON.stringify([ - { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, - { - 'Bundle ID': 'com.apple.inputmethod.SCIM', - 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', - InputSourceKind: 'Input Mode' - } - ]) - }) - ) - registerAppHandlers({} as never) + it.each([true, false])( + 'prioritizes the selected input mode regardless of record order (%s)', + async (modeLast) => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + const inputMode = { + 'Bundle ID': 'com.apple.inputmethod.SCIM', + 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', + InputSourceKind: 'Input Mode' + } + const keyboardLayout = { + InputSourceKind: 'Keyboard Layout', + 'KeyboardLayout Name': 'ABC', + 'KeyboardLayout ID': 252 + } + spawnMock.mockImplementation(() => + createFakeSpawnChild({ + stdout: JSON.stringify([ + { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, + ...(modeLast ? [keyboardLayout, inputMode] : [inputMode, keyboardLayout]) + ]) + }) + ) + registerAppHandlers({} as never) - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.inputmethod.SCIM.ITABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(1) - // Why: macOS 15's `plutil -extract json` aborts on the input-source - // array, so the probe reads live cfprefsd via `defaults export` and dodges - // the bug with an xml1 extract before converting the clean subtree to JSON. - // Pin the exact pipeline (absolute paths, stdin markers) so dropping any - // stage silently regressing CJK detection to the fallback fails the test. - expect(spawnMock).toHaveBeenCalledWith( - '/bin/sh', - [ - '-c', - '/usr/bin/defaults export com.apple.HIToolbox - | ' + - '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + - '/usr/bin/plutil -convert json -o - -' - ], - expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) - ) - }) + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.inputmethod.SCIM.ITABC' + ) + expect(spawnMock).toHaveBeenCalledTimes(1) + // Why: macOS 15's `plutil -extract json` aborts on the input-source + // array, so the probe reads live cfprefsd via `defaults export` and dodges + // the bug with an xml1 extract before converting the clean subtree to JSON. + // Pin the exact pipeline (absolute paths, stdin markers) so dropping any + // stage silently regressing CJK detection to the fallback fails the test. + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + [ + '-c', + '/usr/bin/defaults export com.apple.HIToolbox - | ' + + '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + + '/usr/bin/plutil -convert json -o - -' + ], + expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + ) + } + ) - it('falls back to the keyboard layout when no keyboard input mode is selected', async () => { + it('reads the layout ID only after a selected keyboard layout without a bundle ID is proved', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) spawnMock .mockImplementationOnce(() => @@ -321,6 +330,11 @@ describe('registerAppHandlers', () => { { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' + }, + { + InputSourceKind: 'Keyboard Layout', + 'KeyboardLayout Name': 'ABC', + 'KeyboardLayout ID': 252 } ]) }) @@ -339,42 +353,34 @@ describe('registerAppHandlers', () => { ) }) - it('falls back to the keyboard layout when the selected input source probe exits non-zero', async () => { + it.each([ + { name: 'nonzero exit', result: { code: 1 } }, + { name: 'spawn failure', result: { error: new Error('spawn ENOENT') } }, + { name: 'invalid JSON', result: { stdout: '{' } }, + { name: 'non-array JSON', result: { stdout: '{}' } }, + { name: 'empty records', result: { stdout: '[]' } }, + { name: 'unknown record', result: { stdout: '[{"InputSourceKind":"Unknown"}]' } }, + { + name: 'unidentified input mode', + result: { stdout: '[{"InputSourceKind":"Keyboard Layout"},{"InputSourceKind":"Input Mode"}]' } + }, + { + name: 'non-keyboard record', + result: { + stdout: + '[{"InputSourceKind":"Non Keyboard Input Method","Bundle ID":"com.apple.PressAndHold"}]' + } + } + ])('does not infer the backing layout after $name', async ({ result }) => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - // Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so - // the probe rejects on the `close` branch and the handler falls back. - spawnMock - .mockImplementationOnce(() => createFakeSpawnChild({ code: 1 })) - .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) + spawnMock.mockImplementation(() => createFakeSpawnChild(result)) registerAppHandlers({} as never) - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.keylayout.ABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock).toHaveBeenLastCalledWith( - '/usr/bin/defaults', - ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], - expect.objectContaining({ detached: true }) - ) + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBeNull() + expect(spawnMock).toHaveBeenCalledTimes(1) }) - it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - // Why: a spawn-level failure (ENOENT/EACCES) emits 'error'; the handler must - // still fall back rather than reject out of the IPC call. - spawnMock - .mockImplementationOnce(() => createFakeSpawnChild({ error: new Error('spawn ENOENT') })) - .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) - registerAppHandlers({} as never) - - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.keylayout.ABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - - it('falls back when macOS keyboard input source probes never report completion', async () => { + it('returns unknown and cleans up when the selected-source probe times out', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true })) registerAppHandlers({} as never) @@ -391,9 +397,8 @@ describe('registerAppHandlers', () => { expect(settled).toBe(true) await expect(resultPromise).resolves.toBeNull() - // Why: both wedged probes get a process-group SIGKILL (negative pid) so the - // shell and any orphaned `defaults`/`plutil` stages are reaped on timeout. - expect(processKillSpy).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(processKillSpy).toHaveBeenCalledTimes(1) expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL') }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 17b7663e11b..ea74a240c38 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -183,7 +183,9 @@ function readCommandStdout( }) } -function readSelectedInputSourceIdFromJson(stdout: string): string | null { +type SelectedKeyboardInputSource = { kind: 'inputSource'; id: string } | { kind: 'keyboardLayout' } + +function readSelectedInputSourceFromJson(stdout: string): SelectedKeyboardInputSource | null { let records: unknown try { records = JSON.parse(stdout) @@ -194,54 +196,62 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null { return null } + let hasSelectedKeyboardLayout = false for (const record of records.slice().toReversed()) { if (!record || typeof record !== 'object') { continue } - const fields = record as Record - const kind = typeof fields.InputSourceKind === 'string' ? fields.InputSourceKind : '' - if (kind.toLowerCase().includes('non keyboard')) { + const kind = + 'InputSourceKind' in record && typeof record.InputSourceKind === 'string' + ? record.InputSourceKind.trim().toLowerCase() + : '' + if (kind === 'keyboard layout') { + hasSelectedKeyboardLayout = true continue } - const inputMode = fields['Input Mode'] - if (typeof inputMode === 'string' && inputMode.trim()) { - return inputMode.trim() + if (kind.includes('non keyboard')) { + continue } - const bundleId = fields['Bundle ID'] - if (typeof bundleId === 'string' && bundleId.trim()) { - return bundleId.trim() + if (kind !== 'input mode' && kind !== 'keyboard input method') { + return null } + const inputMode = 'Input Mode' in record ? record['Input Mode'] : undefined + const bundleId = 'Bundle ID' in record ? record['Bundle ID'] : undefined + const id = typeof inputMode === 'string' && inputMode.trim() ? inputMode : bundleId + if (typeof id === 'string' && id.trim()) { + return { kind: 'inputSource', id: id.trim() } + } + return null } - return null + return hasSelectedKeyboardLayout ? { kind: 'keyboardLayout' } : null } -async function readSelectedKeyboardInputSourceId(): Promise { +async function readSelectedKeyboardInputSource(): Promise { try { const stdout = await readCommandStdout( '/bin/sh', ['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND], 'Selected keyboard input source probe timed out' ) - return readSelectedInputSourceIdFromJson(stdout) + return readSelectedInputSourceFromJson(stdout) } catch { return null } } -function readKeyboardLayoutInputSourceId(): Promise { - return readCommandStdout( - '/usr/bin/defaults', - ['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'], - 'Keyboard layout input source probe timed out' - ) -} - async function readKeyboardInputSourceId(): Promise { - const selectedInputSourceId = await readSelectedKeyboardInputSourceId() - if (selectedInputSourceId) { - return selectedInputSourceId + const selectedInputSource = await readSelectedKeyboardInputSource() + if (selectedInputSource?.kind === 'inputSource') { + return selectedInputSource.id } - return readKeyboardLayoutInputSourceId() + // An IME can use ABC underneath; the backing layout alone cannot identify the selected source. + return selectedInputSource?.kind === 'keyboardLayout' + ? readCommandStdout( + '/usr/bin/defaults', + ['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'], + 'Keyboard layout input source probe timed out' + ) + : null } export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void { @@ -270,7 +280,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('pwsh:isAvailable', (): Promise => isPwshAvailableAsync()) ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable()) - // Why: renderer layout fingerprint tags ABC/CJK-Roman as 'us', breaking Option+letter (#1205); HIToolbox prefs override it. + // The selected IME identity must win over its US-shaped backing keyboard layout. ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise => { if (process.platform !== 'darwin') { return null @@ -281,7 +291,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp const trimmed = stdout?.trim() ?? '' return trimmed.length > 0 ? trimmed : null } catch { - // Why: probe can fail (missing keys on first boot, sandbox) — treat as "no signal" and fall back to the fingerprint. + // A failed probe must not promote an IME's backing layout into an Alt default. return null } }) diff --git a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx index f02cbf3d614..bade2bc7741 100644 --- a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx +++ b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx @@ -16,10 +16,19 @@ export function TerminalMacKeyboardSection({ const detectedLayout = useDetectedOptionAsAlt() const detectedLayoutLabel = detectedLayout === 'us' - ? 'US English — Option sends Alt/Esc sequences' + ? translate( + 'settings.terminal.optionLayoutAlt', + 'ABC or U.S. — Option sends Alt/Esc sequences' + ) : detectedLayout === 'non-us' - ? 'non-US layout — Option composes characters like @, €, [, ]' - : 'unknown layout — Option composes characters (safe default)' + ? translate( + 'settings.terminal.optionLayoutCompose', + 'layout uses Option to compose characters like @, €, [, ]' + ) + : translate( + 'settings.terminal.optionLayoutUnknown', + 'unknown layout — Option composes characters (safe default)' + ) return ( <> @@ -49,29 +58,35 @@ export function TerminalMacKeyboardSection({ alignTop label={translate('auto.components.settings.TerminalPane.0a10420e1a', 'Option as Alt')} description={ - settings.terminalMacOptionAsAlt === 'auto' - ? translate( - 'auto.components.settings.TerminalPane.d21c493808', - 'Auto — detected: {{value0}}.', - { - value0: detectedLayoutLabel - } - ) - : settings.terminalMacOptionAsAlt === 'false' + <> + {settings.terminalMacOptionAsAlt === 'auto' ? translate( - 'auto.components.settings.TerminalPane.d8998bb328', - 'Option composes special characters for your keyboard layout.' + 'auto.components.settings.TerminalPane.d21c493808', + 'Auto — detected: {{value0}}.', + { + value0: detectedLayoutLabel + } ) - : settings.terminalMacOptionAsAlt === 'true' + : settings.terminalMacOptionAsAlt === 'false' ? translate( - 'auto.components.settings.TerminalPane.b62373091a', - 'Both Option keys send Alt/Esc sequences.' - ) - : translate( - 'auto.components.settings.TerminalPane.ce3aadf0b2', - 'The {{value0}} Option key sends Alt/Esc; the other composes special characters.', - { value0: settings.terminalMacOptionAsAlt } + 'auto.components.settings.TerminalPane.d8998bb328', + 'Option composes special characters for your keyboard layout.' ) + : settings.terminalMacOptionAsAlt === 'true' + ? translate( + 'auto.components.settings.TerminalPane.b62373091a', + 'Both Option keys send Alt/Esc sequences.' + ) + : translate( + 'auto.components.settings.TerminalPane.ce3aadf0b2', + 'The {{value0}} Option key sends Alt/Esc; the other composes special characters.', + { value0: settings.terminalMacOptionAsAlt } + )}{' '} + {translate( + 'settings.terminal.optionShortcutHint', + 'Choose Both for Option shortcuts, Off for accents and symbols, or Left/Right to use one Option key for each.' + )} + } control={ [0] +type OptionContext = Parameters[1] + +const punctuation = [ + { code: 'Semicolon', key: '…', base: ';', codePoint: 59 }, + { code: 'Period', key: '≥', base: '.', codePoint: 46 }, + { code: 'Comma', key: '≤', base: ',', codePoint: 44 } +] as const + +const altModes = [ + { macOptionAsAlt: 'true', optionKeyLocations: 0 }, + { macOptionAsAlt: 'left', optionKeyLocations: 1 }, + { macOptionAsAlt: 'right', optionKeyLocations: 2 } +] as const + +const composeModes = [ + { macOptionAsAlt: 'false', optionKeyLocations: 0 }, + { macOptionAsAlt: 'left', optionKeyLocations: 2 }, + { macOptionAsAlt: 'right', optionKeyLocations: 1 } +] as const + +function optionEvent(overrides: Partial): OptionEvent { + return { + key: '', + code: '', + altKey: true, + shiftKey: false, + metaKey: false, + ctrlKey: false, + repeat: false, + ...overrides + } +} + +function optionContext(overrides: Partial = {}): OptionContext { + return { + isMac: true, + macOptionAsAlt: 'true', + optionKeyLocations: 0, + getKittyKeyboardFlags: () => 1, + ...overrides + } +} + +describe.each(punctuation)('Option+$base punctuation ($code)', ({ code, key, base, codePoint }) => { + it.each([1, 7])('reports the configured Alt side under keyboard flags %i', (flags) => { + for (const mode of altModes) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => flags }) + ) + ).toEqual({ + type: 'sendInput', + data: `\x1b[${codePoint};3u`, + optionKittyRelease: flags === 7 ? { flags } : undefined + }) + } + }) + + it('reports repeats and one release for the original punctuation key', () => { + for (const mode of altModes) { + const sendInput = vi.fn() + const releases = createTerminalOptionKittyReleaseTracker() + const context = optionContext({ ...mode, getKittyKeyboardFlags: () => 7 }) + for (const repeat of [false, true]) { + const event = optionEvent({ code, key, repeat }) + const action = resolveTerminalOptionShortcutAction(event, context) + expect(action).toEqual({ + type: 'sendInput', + data: `\x1b[${codePoint};3${repeat ? ':2' : ''}u`, + optionKittyRelease: { flags: 7 } + }) + if (action?.type === 'sendInput' && action.optionKittyRelease) { + sendInput(action.data) + releases.arm(event, action.optionKittyRelease, sendInput, context.getKittyKeyboardFlags) + } + } + const release = optionEvent({ code, key: base, altKey: false }) + expect(releases.settle(release)).toBe(true) + expect(releases.settle(release)).toBe(false) + expect(sendInput.mock.calls.map(([data]) => data)).toEqual([ + `\x1b[${codePoint};3u`, + `\x1b[${codePoint};3:2u`, + `\x1b[${codePoint};1:3u` + ]) + } + }) + + it.each([1, 7])('preserves the composed symbol on the text side under flags %i', (flags) => { + for (const mode of composeModes) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => flags }) + ) + ).toEqual({ + type: 'sendInput', + data: key, + optionKittyRelease: flags === 7 ? { flags } : undefined + }) + } + }) + + it('uses legacy escape bytes for a selected Alt side in an ordinary shell', () => { + for (const mode of altModes.slice(1)) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 0 }) + ) + ).toEqual({ type: 'sendInput', data: `\x1b${base}` }) + } + for (const mode of [altModes[0], ...composeModes]) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 0 }) + ) + ).toBeNull() + } + }) + + it.each([{ isComposing: true }, { keyCode: 229 }, { key: 'Process' }, { key: 'Unidentified' }])( + 'leaves IME-owned punctuation to native input (%j)', + (imeState) => { + for (const mode of [...altModes, ...composeModes]) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key, ...imeState }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 7 }) + ) + ).toBeNull() + } + } + ) + + it('leaves non-Mac input and additional command modifiers untouched', () => { + for (const overrides of [{ isMac: false }, { isMac: true }]) { + const context = optionContext(overrides) + const events = + overrides.isMac === false + ? [optionEvent({ code, key })] + : [optionEvent({ code, key, ctrlKey: true }), optionEvent({ code, key, metaKey: true })] + for (const event of events) { + expect(resolveTerminalOptionShortcutAction(event, context)).toBeNull() + } + } + }) +}) + +it('uses the active layout character rather than the US punctuation position', () => { + const event = optionEvent({ code: 'Semicolon', key: 'µ' }) + const layout = (code: string): string | undefined => (code === 'Semicolon' ? 'm' : undefined) + for (const mode of altModes) { + expect( + resolveTerminalOptionShortcutAction( + event, + optionContext({ ...mode, layoutCharacterForCode: layout, getKittyKeyboardFlags: () => 7 }) + ) + ).toEqual({ + type: 'sendInput', + data: '\x1b[109::59;3u', + optionKittyRelease: { flags: 7 } + }) + } + for (const mode of altModes.slice(1)) { + expect( + resolveTerminalOptionShortcutAction( + event, + optionContext({ ...mode, layoutCharacterForCode: layout, getKittyKeyboardFlags: () => 0 }) + ) + ).toEqual({ type: 'sendInput', data: '\x1bm' }) + } +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 48f6eec3cb1..21fcdf4beea 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -127,6 +127,12 @@ "retrying": "Retrying…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC or U.S. — Option sends Alt/Esc sequences", + "optionLayoutCompose": "layout uses Option to compose characters like @, €, [, ]", + "optionLayoutUnknown": "unknown layout — Option composes characters (safe default)", + "optionShortcutHint": "Choose Both for Option shortcuts, Off for accents and symbols, or Left/Right to use one Option key for each." + }, "appearance": { "language": { "title": "Language", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 84dffe050fd..c1dc27fd97d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11,6 +11,12 @@ "retrying": "Reintentando…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC o EE. UU. — Option envía secuencias Alt/Esc", + "optionLayoutCompose": "la distribución usa Option para escribir caracteres como @, €, [, ]", + "optionLayoutUnknown": "distribución desconocida — Option escribe caracteres (opción predeterminada segura)", + "optionShortcutHint": "Elige Ambos para los atajos con Option, Desactivado para acentos y símbolos, o Izquierda/Derecha para dedicar una tecla Option a cada uso." + }, "appearance": { "language": { "title": "Idioma", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8086183f21..cc9d5683689 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -110,6 +110,12 @@ "retrying": "Nouvelle tentative…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC ou U.S. — Option envoie des séquences Alt/Esc", + "optionLayoutCompose": "la disposition utilise Option pour composer des caractères comme @, €, [, ]", + "optionLayoutUnknown": "disposition inconnue — Option compose des caractères (choix sûr par défaut)", + "optionShortcutHint": "Choisissez Les deux pour les raccourcis Option, Désactivé pour les accents et les symboles, ou Gauche/Droite pour attribuer un usage à chaque touche Option." + }, "appearance": { "language": { "title": "Langue", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0997b8b616b..485f45e6e6f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11,6 +11,12 @@ "retrying": "再試行中…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABCまたはU.S. — OptionはAlt/Escシーケンスを送信します", + "optionLayoutCompose": "この配列ではOptionで@、€、[、]などの文字を入力します", + "optionLayoutUnknown": "不明な配列 — Optionで文字を入力します(安全な既定値)", + "optionShortcutHint": "Optionショートカットには「両方」、アクセント付き文字や記号には「オフ」、用途を左右のOptionキーに分けるには「左」または「右」を選んでください。" + }, "appearance": { "language": { "title": "言語", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 29149cd5a65..23f07a9d2e9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11,6 +11,12 @@ "retrying": "재시도 중…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC 또는 U.S. — Option 키가 Alt/Esc 시퀀스를 전송합니다", + "optionLayoutCompose": "이 배열에서는 Option 키로 @, €, [, ] 같은 문자를 입력합니다", + "optionLayoutUnknown": "알 수 없는 배열 — Option 키로 문자를 입력합니다(안전한 기본값)", + "optionShortcutHint": "Option 단축키에는 둘 다, 악센트 문자와 기호에는 끄기, 각 Option 키를 다른 용도로 쓰려면 왼쪽 또는 오른쪽을 선택하세요." + }, "appearance": { "language": { "title": "언어", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d907237dea8..6b13b751dd6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11,6 +11,12 @@ "retrying": "正在重试..." }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC 或 U.S. — Option 键发送 Alt/Esc 序列", + "optionLayoutCompose": "此布局使用 Option 键输入 @、€、[、] 等字符", + "optionLayoutUnknown": "未知布局 — Option 键输入字符(安全的默认设置)", + "optionShortcutHint": "选择“两个都”使用 Option 快捷键,选择“关”输入重音字符和符号,或选择“左边”/“右边”让两个 Option 键各司其职。" + }, "appearance": { "language": { "title": "语言", @@ -8549,7 +8555,7 @@ "19f4935159": "JIS 日元 (¥) 至 反斜杠 (\\\\)", "1c337bef4a": "控制按 JIS 日元 (¥) 键是否发送反斜杠 (\\\\)。", "3fe1c5bfe0": "关", - "c73d510938": "正确的", + "c73d510938": "右边", "e7aec1fd60": "左边", "badb1219fc": "两个都", "43c2ff7b0e": "自动", diff --git a/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts b/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts index da681a78954..e45faa0ed36 100644 --- a/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts +++ b/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts @@ -10,11 +10,12 @@ * The only defensible default is the one that varies per layout. This * module fingerprints the active layout from Chromium's * navigator.keyboard.getLayoutMap() (ships in Chrome 69+, so every Electron - * we could run). The base layer cannot separate US from US-International or - * ABC, so every US-shaped layout maps to `true` here and `input-source-id.ts` - * narrows that to plain US whenever macOS gives us the real input source ID. + * we could run). The base layer cannot separate standard ABC/US from + * US-International, so every US-shaped layout maps to `true` here and + * `input-source-id.ts` narrows that to ABC and US using the native input source ID. * Everything else — Dvorak, Colemak, UK, every international layout — maps - * to `false`. + * to `false`. Missing native identity on macOS also stays conservative: + * an IME can expose a US-shaped backing layout without identifying itself. * */ @@ -59,7 +60,7 @@ export type DetectedLayoutCategory = * Semicolon (`o` vs `;`). Dvorak fails KeyQ immediately. Both get classified * as `non-us` and default to `'false'`; users who want `'true'` flip the * explicit override. The native input-source classifier distinguishes - * plain US from US-shaped composition layouts. + * standard ABC/US from US-shaped international composition layouts. */ const US_FINGERPRINT: Record = { KeyQ: 'q', diff --git a/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts b/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts index 215d19666a7..4ba160a6632 100644 --- a/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts +++ b/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts @@ -8,8 +8,8 @@ describe('classifyInputSourceId', () => { expect(classifyInputSourceId('')).toBe('unknown') }) - it('allowlists plain US Standard as meta', () => { - expect(classifyInputSourceId('com.apple.keylayout.US')).toBe('meta') + it.each(['US', 'ABC'])('allowlists standard %s as meta', (name) => { + expect(classifyInputSourceId(`com.apple.keylayout.${name}`)).toBe('meta') }) it('classifies US International PC as compose (Option+C → ç repro)', () => { @@ -19,13 +19,8 @@ describe('classifyInputSourceId', () => { it('is case-insensitive on the allowlist (defaults differ between macOS versions)', () => { expect(classifyInputSourceId('COM.APPLE.KEYLAYOUT.US')).toBe('meta') expect(classifyInputSourceId('com.apple.keylayout.us')).toBe('meta') - }) - - it('classifies ABC as compose (the user-reported Option+A → å repro)', () => { - // ABC looks US on the base layer but composes Option+A → å. Pre-fix, - // the fingerprint alone drove the decision and flipped - // macOptionIsMeta=true, silently swallowing the composition. - expect(classifyInputSourceId('com.apple.keylayout.ABC')).toBe('compose') + expect(classifyInputSourceId('COM.APPLE.KEYLAYOUT.ABC')).toBe('meta') + expect(classifyInputSourceId('com.apple.keylayout.abc')).toBe('meta') }) it('classifies Polish Pro as compose (#1205)', () => { @@ -38,9 +33,7 @@ describe('classifyInputSourceId', () => { }) it('classifies every other Apple-shipped layout as compose (default-deny)', () => { - // Only plain US is allowlisted; everything else (Dvorak, Colemak, - // German, French, Turkish, Spanish, Swedish, every CJK Roman IME) - // falls back to compose. + // Only standard ABC/US are allowlisted; other layouts retain composition. expect(classifyInputSourceId('com.apple.keylayout.Dvorak')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.Colemak')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.German')).toBe('compose') @@ -51,10 +44,13 @@ describe('classifyInputSourceId', () => { expect(classifyInputSourceId('com.apple.inputmethod.Korean.2SetKorean')).toBe('compose') }) - it('does not prefix-leak the US allowlist into extended variants', () => { - // `com.apple.keylayout.US` must not silently allowlist `USExtended`. - // The matcher is full-ID equality (case-insensitive), not prefix. + it('does not prefix-leak the standard allowlist into extended or custom variants', () => { expect(classifyInputSourceId('com.apple.keylayout.USExtended')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.US.variant')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABCExtended')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABC.variant')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABCInternational')).toBe('compose') + expect(classifyInputSourceId('org.custom.keylayout.ABC')).toBe('compose') + expect(classifyInputSourceId('unknown')).toBe('compose') }) }) diff --git a/src/renderer/src/lib/keyboard-layout/input-source-id.ts b/src/renderer/src/lib/keyboard-layout/input-source-id.ts index 4f5022fb739..1eb2906ffeb 100644 --- a/src/renderer/src/lib/keyboard-layout/input-source-id.ts +++ b/src/renderer/src/lib/keyboard-layout/input-source-id.ts @@ -1,17 +1,19 @@ -// The base-layer probe cannot distinguish US from layouts whose Option layer composes text. -const META_INPUT_SOURCE_IDS: readonly string[] = ['com.apple.keylayout.us'] +// ABC and US share the standard layout; international variants retain Option composition. +const META_INPUT_SOURCE_IDS: readonly string[] = [ + 'com.apple.keylayout.us', + 'com.apple.keylayout.abc' +] export type InputSourceOverride = - /** Option-as-Meta is safe on this input source. Resolves to `'us'` + /** Auto uses Option-as-Meta on this standard input source. Resolves to `'us'` * for `effectiveMacOptionAsAlt`. */ | 'meta' /** Option composes layout characters on this input source. Resolves * to `'non-us'` so `macOptionIsMeta` stays off and compositions like - * Option+A → å / ą reach the shell. */ + * Option+A → ą reach the shell. */ | 'compose' - /** No macOS input source ID available (non-Darwin, IPC failure, - * sandboxed defaults). The caller should fall back to the layout - * fingerprint. */ + /** No input source ID available. macOS stays conservative; other + * platforms may use the layout fingerprint. */ | 'unknown' export function classifyInputSourceId(id: string | null | undefined): InputSourceOverride { diff --git a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts index 3af50308e8c..05b777b2254 100644 --- a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts +++ b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createOptionAsAltProbe } from './option-as-alt-probe' -import type { LayoutMapLike } from './detect-option-as-alt' +import { effectiveMacOptionAsAlt, type LayoutMapLike } from './detect-option-as-alt' import type { KeyboardLayoutChangeEvent } from '../../../../shared/keyboard-layout-events' const US_MAP: LayoutMapLike = { @@ -37,6 +37,7 @@ const TURKISH_MAP: LayoutMapLike = { type MockWindow = { navigator: { + userAgent: string keyboard?: { getLayoutMap: () => Promise } } addEventListener: (type: string, fn: EventListener) => void @@ -44,11 +45,12 @@ type MockWindow = { fireFocus: () => void } -function makeMockWindow(initial: LayoutMapLike | null): MockWindow { +function makeMockWindow(initial: LayoutMapLike | null, userAgent = 'Linux'): MockWindow { const focusListeners = new Set() let current = initial return { navigator: { + userAgent, keyboard: current ? { getLayoutMap: vi.fn(async () => current!) @@ -82,24 +84,29 @@ describe('createOptionAsAltProbe', () => { vi.unstubAllGlobals() }) - it('uses the native snapshot identity before the preference fallback', async () => { - const getKeyboardLayoutSnapshot = vi.fn(async () => ({ - inputSourceId: 'com.apple.keylayout.ABC', - keyCharacters: {} - })) - const getKeyboardInputSourceId = vi.fn(async () => 'com.apple.keylayout.US') - vi.stubGlobal('window', { - api: { app: { getKeyboardLayoutSnapshot, getKeyboardInputSourceId } } - }) - const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window) + it.each(['com.apple.keylayout.ABCExtended', 'com.apple.inputmethod.SCIM.ITABC'])( + 'uses the native input source %s before its backing layout or preference', + async (inputSourceId) => { + const getKeyboardLayoutSnapshot = vi.fn(async () => ({ + inputSourceId, + layoutSourceId: 'com.apple.keylayout.ABC', + keyCharacters: {} + })) + const getKeyboardInputSourceId = vi.fn(async () => 'com.apple.keylayout.US') + vi.stubGlobal('window', { + api: { app: { getKeyboardLayoutSnapshot, getKeyboardInputSourceId } } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(makeMockWindow(US_MAP, 'Macintosh') as unknown as Window) - await probe.refresh() + await probe.refresh() - expect(probe.getCurrent()).toBe('non-us') - expect(getKeyboardLayoutSnapshot).toHaveBeenCalled() - expect(getKeyboardInputSourceId).not.toHaveBeenCalled() - probe.dispose() - }) + expect(probe.getCurrent()).toBe('non-us') + expect(getKeyboardLayoutSnapshot).toHaveBeenCalled() + expect(getKeyboardInputSourceId).not.toHaveBeenCalled() + probe.dispose() + } + ) it('starts as unknown, upgrades after first probe resolves', async () => { const win = makeMockWindow(US_MAP) @@ -158,8 +165,8 @@ describe('createOptionAsAltProbe', () => { probe.dispose() }) - it('invalidates immediately and refreshes on a native layout-change notification', async () => { - let activeInputSourceId = 'com.apple.keylayout.US' + it('updates Auto on ABC/international switches while preserving every explicit mode', async () => { + let activeInputSourceId = 'com.apple.keylayout.ABC' let notifyLayoutChanged: (() => void) | undefined const unsubscribe = vi.fn() const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { @@ -169,15 +176,29 @@ describe('createOptionAsAltProbe', () => { return unsubscribe } }) + const expectEffectiveModes = (automatic: 'true' | 'false') => { + expect(effectiveMacOptionAsAlt('auto', probe.getCurrent())).toBe(automatic) + for (const mode of ['true', 'false', 'left', 'right'] as const) { + expect(effectiveMacOptionAsAlt(mode, probe.getCurrent())).toBe(mode) + } + } await probe.refresh() expect(probe.getCurrent()).toBe('us') + expectEffectiveModes('true') - activeInputSourceId = 'com.apple.keylayout.ABC' - notifyLayoutChanged?.() - expect(probe.getCurrent()).toBe('unknown') - await Promise.resolve() - await Promise.resolve() - expect(probe.getCurrent()).toBe('non-us') + for (const [id, category, automatic] of [ + ['USInternational-PC', 'non-us', 'false'], + ['ABC', 'us', 'true'] + ] as const) { + activeInputSourceId = `com.apple.keylayout.${id}` + notifyLayoutChanged?.() + expect(probe.getCurrent()).toBe('unknown') + expectEffectiveModes('false') + await Promise.resolve() + await Promise.resolve() + expect(probe.getCurrent()).toBe(category) + expectEffectiveModes(automatic) + } probe.dispose() expect(unsubscribe).toHaveBeenCalledOnce() @@ -192,7 +213,7 @@ describe('createOptionAsAltProbe', () => { const readInputSourceId = vi .fn<() => Promise>() .mockReturnValueOnce(oldRead) - .mockResolvedValue('com.apple.keylayout.ABC') + .mockResolvedValue('com.apple.keylayout.PolishPro') const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { readInputSourceId, subscribeKeyboardLayoutChanged: (callback) => { @@ -202,7 +223,7 @@ describe('createOptionAsAltProbe', () => { }) notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) - finishOldRead('com.apple.keylayout.US') + finishOldRead('com.apple.keylayout.ABC') await Promise.resolve() await Promise.resolve() expect(probe.getCurrent()).toBe('unknown') @@ -281,11 +302,13 @@ describe('createOptionAsAltProbe', () => { }) it('forces non-us when the input source ID is not on the Option-as-Meta allowlist (#1205)', async () => { - // ABC and Polish Pro both report a US-identical base layer to - // getLayoutMap(); without the input-source override they would classify - // as 'us' → macOptionIsMeta=true and swallow every Option+letter - // composition (Option+A → å on ABC, ą on Polish Pro). - for (const id of ['com.apple.keylayout.ABC', 'com.apple.keylayout.PolishPro']) { + // The native ID protects composition even when the base layer matches US. + for (const id of [ + 'com.apple.keylayout.USInternational-PC', + 'com.apple.keylayout.USExtended', + 'com.apple.keylayout.ABCExtended', + 'com.apple.keylayout.PolishPro' + ]) { const win = makeMockWindow(US_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => id @@ -296,27 +319,98 @@ describe('createOptionAsAltProbe', () => { } }) - it('resolves to us when the input source ID is plain US (allowlist match)', async () => { - const win = makeMockWindow(US_MAP) - const probe = createOptionAsAltProbe(win as unknown as Window, { - readInputSourceId: async () => 'com.apple.keylayout.US' - }) + it.each(['US', 'ABC'])( + 'resolves to us for standard %s without a browser layout map', + async (id) => { + const win = makeMockWindow(null, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => `com.apple.keylayout.${id}` + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + probe.dispose() + } + ) + + it.each(['Linux', 'Windows'])( + 'uses the fingerprint without native identity on %s', + async (userAgent) => { + const win = makeMockWindow(US_MAP, userAgent) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => null + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + probe.dispose() + } + ) + + it.each(['unavailable', 'rejected'] as const)( + 'stays conservative on macOS when current-source identity is %s', + async (result) => { + const win = makeMockWindow(US_MAP, 'Macintosh') + const readInputSourceId = vi.fn(async () => { + if (result === 'rejected') { + throw new Error('identity unavailable') + } + return null + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId }) + await probe.refresh() + expect(probe.getCurrent()).toBe('unknown') + expect(effectiveMacOptionAsAlt('auto', probe.getCurrent())).toBe('false') + for (const mode of ['true', 'false', 'left', 'right'] as const) { + expect(effectiveMacOptionAsAlt(mode, probe.getCurrent())).toBe(mode) + } + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() + probe.dispose() + } + ) + + it('stays conservative on macOS without either native identity API', async () => { + vi.stubGlobal('window', { api: { app: {} } }) + const win = makeMockWindow(US_MAP, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window) await probe.refresh() - expect(probe.getCurrent()).toBe('us') + expect(probe.getCurrent()).toBe('unknown') + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() probe.dispose() }) - it('falls back to the fingerprint when the input-source reader returns null (non-Darwin)', async () => { - const win = makeMockWindow(US_MAP) + it('recovers macOS source identity after losing it without trusting the backing map', async () => { + let activeInputSourceId: string | null = 'com.apple.keylayout.ABC' + const win = makeMockWindow(US_MAP, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. const probe = createOptionAsAltProbe(win as unknown as Window, { - readInputSourceId: async () => null + readInputSourceId: async () => activeInputSourceId }) - await probe.refresh() - expect(probe.getCurrent()).toBe('us') + const listener = vi.fn() + probe.subscribe(listener) + for (const [id, category] of [ + ['com.apple.keylayout.ABC', 'us'], + [null, 'unknown'], + ['com.apple.inputmethod.SCIM.ITABC', 'non-us'], + ['com.apple.keylayout.ABC', 'us'] + ] as const) { + activeInputSourceId = id + await probe.refresh() + expect(probe.getCurrent()).toBe(category) + } + expect(listener.mock.calls.map(([category]) => category)).toEqual([ + 'us', + 'unknown', + 'non-us', + 'us' + ]) + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() probe.dispose() }) - it('falls back to the fingerprint when the input-source reader throws', async () => { + it('falls back to the fingerprint off macOS when the input-source reader throws', async () => { const win = makeMockWindow(TURKISH_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => { @@ -329,10 +423,8 @@ describe('createOptionAsAltProbe', () => { }) it('re-probes the input source ID on focus-in so mid-session layout switches are picked up', async () => { - // Simulate: user boots on US, flips to ABC via the Input Source menu, - // Orca regains focus. Fingerprint stays US the whole time; the - // input-source override is what notices the switch. - let activeInputSourceId: string | null = 'com.apple.keylayout.US' + // The browser fingerprint stays US while the native identity changes. + let activeInputSourceId: string | null = 'com.apple.keylayout.ABC' const win = makeMockWindow(US_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => activeInputSourceId @@ -340,7 +432,7 @@ describe('createOptionAsAltProbe', () => { await probe.refresh() expect(probe.getCurrent()).toBe('us') - activeInputSourceId = 'com.apple.keylayout.ABC' + activeInputSourceId = 'com.apple.keylayout.USInternational-PC' win.fireFocus() // Let the focus-triggered probe resolve. await Promise.resolve() @@ -368,13 +460,64 @@ describe('createOptionAsAltProbe', () => { }) const newestProbe = probe.refresh() - resolveNew('com.apple.keylayout.ABC') + resolveNew('com.apple.keylayout.PolishPro') await newestProbe expect(probe.getCurrent()).toBe('non-us') - resolveOld('com.apple.keylayout.US') + resolveOld('com.apple.keylayout.ABC') await Promise.resolve() await Promise.resolve() expect(probe.getCurrent()).toBe('non-us') probe.dispose() }) + + it.each(['before', 'after'] as const)( + 'fences superseded layout generations when the stale read resolves %s the newest', + async (order) => { + let activeRead: Promise = Promise.resolve('com.apple.keylayout.ABC') + let notifyLayoutChanged: ((event: KeyboardLayoutChangeEvent) => void) | undefined + const readInputSourceId = vi.fn(() => activeRead) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { + readInputSourceId, + subscribeKeyboardLayoutChanged: (callback) => { + notifyLayoutChanged = callback + return vi.fn() + } + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + const listener = vi.fn() + probe.subscribe(listener) + + const staleRead = Promise.withResolvers() + const newestRead = Promise.withResolvers() + activeRead = staleRead.promise + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + notifyLayoutChanged?.({ phase: 'refresh', generation: 1 }) + notifyLayoutChanged?.({ phase: 'invalidated', generation: 2 }) + const readsBeforeStaleNotifications = readInputSourceId.mock.calls.length + notifyLayoutChanged?.({ phase: 'refresh', generation: 1 }) + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + expect(readInputSourceId).toHaveBeenCalledTimes(readsBeforeStaleNotifications) + + activeRead = newestRead.promise + notifyLayoutChanged?.({ phase: 'refresh', generation: 2 }) + if (order === 'before') { + staleRead.resolve('com.apple.keylayout.PolishPro') + await Promise.resolve() + expect(probe.getCurrent()).toBe('unknown') + } + newestRead.resolve('com.apple.keylayout.ABC') + await Promise.resolve() + expect(probe.getCurrent()).toBe('us') + if (order === 'after') { + staleRead.resolve('com.apple.keylayout.PolishPro') + await Promise.resolve() + } + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + expect(probe.getCurrent()).toBe('us') + expect(listener.mock.calls.map(([category]) => category)).toEqual(['unknown', 'us']) + probe.dispose() + } + ) }) diff --git a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts index 2ae80dff636..5694969a91a 100644 --- a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts +++ b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts @@ -4,16 +4,12 @@ * Runs at boot, on native macOS input-source notifications, and on focus as a * fallback. The browser Keyboard API has no usable layout-change event. * - * Why two signals (input source ID + fingerprint): the fingerprint can - * only see the base (unshifted) layer, which is identical to US QWERTY - * on a large set of Apple-shipped layouts — ABC, Polish Pro, US - * Extended, ABC Extended, and every CJK Roman IME all trap on it. They - * repurpose Option for dead-key composition (Option+A → å / ą), so - * trusting the fingerprint alone makes macOptionIsMeta=true and - * silently swallows those characters (issue #1205). On macOS we treat - * the input source ID as authoritative and only fall back to the - * fingerprint when the ID is unavailable (non-Darwin, sandboxed - * defaults, IPC failure). See ./input-source-id.ts for the allowlist. + * The base-layer fingerprint cannot distinguish standard ABC/US from + * composition layouts such as Polish Pro, US Extended, ABC Extended, + * and CJK Roman IMEs. Native identity protects their Option text (#1205); + * macOS stays conservative without native identity; other platforms use + * the browser fingerprint as a fallback. + * See ./input-source-id.ts for the exact standard-layout allowlist. */ import { detectOptionAsAltFromLayoutMap, @@ -100,9 +96,7 @@ function defaultInputSourceIdReader(): InputSourceIdReader { try { return await reader() } catch { - // Why: the IPC can transiently reject during main-process teardown - // (e.g. app quitting mid-probe). Treat as no signal so the - // fingerprint remains the sole input. + // Missing identity stays conservative on macOS, including during teardown. return null } } @@ -118,6 +112,7 @@ export function createOptionAsAltProbe( let probeGeneration = 0 let layoutChangeGeneration = 0 let layoutRefreshBlocked = false + const isMac = win.navigator.userAgent.includes('Mac') const readInputSourceId = options.readInputSourceId ?? defaultInputSourceIdReader() const subscribeKeyboardLayoutChanged = options.subscribeKeyboardLayoutChanged ?? defaultKeyboardLayoutChangeSubscriber() @@ -144,14 +139,12 @@ export function createOptionAsAltProbe( const nav = win.navigator as NavigatorWithKeyboard const keyboard = nav?.keyboard - // Why: read the input-source ID first. On macOS this resolves to a - // concrete ID (e.g. com.apple.keylayout.ABC); on every other platform - // it resolves to null and we fall through to the fingerprint. + // Read current-source identity before trusting a potentially IME-backed base layer. let inputSourceId: string | null = null try { inputSourceId = await readInputSourceId() } catch { - // Treat errors as no signal — the fingerprint still runs below. + // Missing identity stays conservative on macOS. inputSourceId = null } @@ -169,6 +162,10 @@ export function createOptionAsAltProbe( notify('non-us') return } + if (isMac) { + notify('unknown') + return + } if (!keyboard?.getLayoutMap) { // Non-Chromium or Electron stripped of the Keyboard API. Stay at diff --git a/tests/e2e/terminal-option-composed-ascii.spec.ts b/tests/e2e/terminal-option-composed-ascii.spec.ts index 8aec5f4471c..a060d655900 100644 --- a/tests/e2e/terminal-option-composed-ascii.spec.ts +++ b/tests/e2e/terminal-option-composed-ascii.spec.ts @@ -1,161 +1,232 @@ // Option composition must survive kitty negotiation (#14024, #20171, #20850). import { test, expect } from './helpers/orca-app' -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { focusActiveTerminalInput, waitForTerminalOutput } from './helpers/terminal' +import { clearTerminalPtyWriteLog as clearPtyWriteLog } from './helpers/terminal-pty-write-spy' import { - execInTerminal, - waitForTerminalOutput, - waitForActiveTerminalManager, - waitForActivePanePtyId -} from './helpers/terminal' -import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' -import { - clearTerminalPtyWriteLog as clearPtyWriteLog, - installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, - readTerminalPtyWrites as getPtyWrites -} from './helpers/terminal-pty-write-spy' - -type MacOptionAsAltSetting = 'auto' | 'true' | 'false' | 'left' | 'right' - -async function setMacOptionAsAlt(page: Page, value: MacOptionAsAltSetting): Promise { - await page.evaluate(async (value) => { - await window.__store?.getState().updateSettings({ terminalMacOptionAsAlt: value }) - }, value) - await expect - .poll( - async () => - page.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt ?? null), - { timeout: 5_000, message: 'terminalMacOptionAsAlt did not apply' } - ) - .toBe(value) -} - -/** Reads the pane's mirrored kitty flags — the exact value the policy consults. */ -async function getPaneKittyKeyboardFlags(page: Page): Promise { - return page.evaluate(() => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const terminal = pane?.terminal as - | { - core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - } - | undefined - return ( - terminal?.core?.coreService?.kittyKeyboard?.flags ?? - terminal?._core?.coreService?.kittyKeyboard?.flags ?? - 0 - ) - }) -} - -/** - * Dispatches the keydown macOS delivers for an Option-composed key: `key` is - * already the composed glyph while `code` still names the physical key. - */ -async function pressOptionComposedKey( - page: Page, - press: { key: string; code: string; shiftKey?: boolean } -): Promise<{ keydownDefaultPrevented: boolean }> { - return page.evaluate((press) => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const textarea = pane?.container.querySelector( - '.xterm-helper-textarea' - ) as HTMLTextAreaElement | null - if (!pane || !textarea) { - throw new Error('No active terminal textarea for the Option chord dispatch') - } - pane.terminal.focus() - textarea.focus() - - // Why: the policy resolves left-vs-right Option from the modifier's own - // keydown, so the chord has to be preceded by a real AltLeft press. - const modifierInit = { key: 'Alt', code: 'AltLeft', altKey: true, bubbles: true } - const altDown = new KeyboardEvent('keydown', modifierInit) - Object.defineProperty(altDown, 'location', { get: () => 1 }) - textarea.dispatchEvent(altDown) - - const keydown = new KeyboardEvent('keydown', { - key: press.key, - code: press.code, - altKey: true, - shiftKey: press.shiftKey === true, - bubbles: true, - cancelable: true - }) - textarea.dispatchEvent(keydown) - - textarea.dispatchEvent( - new KeyboardEvent('keyup', { - key: press.key, - code: press.code, - altKey: true, - shiftKey: press.shiftKey === true, - bubbles: true, - cancelable: true - }) - ) - const altUp = new KeyboardEvent('keyup', modifierInit) - Object.defineProperty(altUp, 'location', { get: () => 1 }) - textarea.dispatchEvent(altUp) - - return { keydownDefaultPrevented: keydown.defaultPrevented } - }, press) -} - -async function armKittyKeyboardFromPty(page: Page, ptyId: string, flags: number): Promise { - // Why: this is the byte a real kitty-protocol TUI pushes at startup; routing it - // through the PTY exercises the same output-scanning mirror the policy reads. - // `cat` stays in the foreground: flags left armed at exit are grounded by the host. - await execInTerminal(page, ptyId, `printf '\\033[>${flags}u'; cat`) - await expect - .poll(async () => getPaneKittyKeyboardFlags(page), { - timeout: 15_000, - message: 'the pane never mirrored the application kitty keyboard flags' - }) - .toBe(flags) -} - -async function setUpPane( - page: Page, - app: ElectronApplication, - kittyFlags = 1 -): Promise<{ joinedWrites: () => Promise }> { - await waitForSessionReady(page) - await waitForActiveWorktree(page) - await ensureTerminalVisible(page) - await waitForActiveTerminalManager(page) - const ptyId = await waitForActivePanePtyId(page) - await installMainProcessPtyWriteSpy(app) - await armKittyKeyboardFromPty(page, ptyId, kittyFlags) - return { joinedWrites: async () => (await getPtyWrites(app)).join('') } -} + setMacOptionAsAlt, + pressOptionComposedKey, + setUpOptionKeyboardPane, + publishMacKeyboardLayout, + waitForPaneOptionAsAlt, + pressChromiumOptionPunctuation +} from './terminal-option-key-input' test.describe('Option-composed text in a kitty-keyboard pane', () => { test.skip(process.platform !== 'darwin', 'Option composition is a macOS-only input path (#14024)') + test('the settings control enables punctuation shortcuts and keeps the other Option side as text', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + await state?.updateSettings({ uiLanguage: 'en' }) + state?.openSettingsTarget({ pane: 'terminal', repoId: null }) + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + const control = orcaPage.getByRole('radiogroup', { name: 'Option as Alt', exact: true }) + await expect( + orcaPage.getByText(/Choose Both for Option shortcuts, Off for accents and symbols/) + ).toBeVisible() + const keys = [ + { key: '…', code: 'Semicolon', codePoint: 59 }, + { key: '≥', code: 'Period', codePoint: 46 }, + { key: '≤', code: 'Comma', codePoint: 44 } + ] + for (const [label, setting] of [ + ['Both', 'true'], + ['Left', 'left'], + ['Right', 'right'] + ] as const) { + await control.getByRole('radio', { name: label, exact: true }).click() + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ) + .toBe(setting) + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + for (const side of ['left', 'right'] as const) { + await clearPtyWriteLog(electronApp) + for (const key of keys) { + await pressOptionComposedKey(orcaPage, { ...key, side }) + } + const isAlt = setting === 'true' || setting === side + const expected = keys + .map( + ({ key, codePoint }) => `${isAlt ? `\x1b[${codePoint};3u` : key}\x1b[${codePoint};3:3u` + ) + .join('') + await expect.poll(joinedWrites).toBe(expected) + } + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + } + }) + + test('Chromium Option punctuation produces text in compose mode and shortcuts in Both mode', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + const cdp = await orcaPage.context().newCDPSession(orcaPage) + try { + for (const setting of ['false', 'true'] as const) { + await setMacOptionAsAlt(orcaPage, setting) + await clearPtyWriteLog(electronApp) + for (const key of [ + { key: '…', code: 'Semicolon', base: ';', codePoint: 59, windowsVirtualKeyCode: 186 }, + { key: '≥', code: 'Period', base: '.', codePoint: 46, windowsVirtualKeyCode: 190 }, + { key: '≤', code: 'Comma', base: ',', codePoint: 44, windowsVirtualKeyCode: 188 } + ]) { + await pressChromiumOptionPunctuation(cdp, key) + } + const expected = [ + ['…', 59], + ['≥', 46], + ['≤', 44] + ] + .map( + ([key, codePoint]) => + `${setting === 'true' ? `\x1b[${codePoint};3u` : key}\x1b[${codePoint};3:3u` + ) + .join('') + await expect.poll(joinedWrites).toBe(expected) + if (setting === 'false') { + for (const glyph of ['…', '≥', '≤']) { + await waitForTerminalOutput(orcaPage, glyph) + } + } + } + } finally { + await cdp.detach() + } + }) + + test('Auto follows ABC and composing input sources without replacing explicit Option settings', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ uiLanguage: 'en' }) + }) + await setMacOptionAsAlt(orcaPage, 'auto') + const abc = 'com.apple.keylayout.ABC' + const international = 'com.apple.keylayout.USInternational-PC' + const pinyin = 'com.apple.inputmethod.SCIM.ITABC' + const keyCharacters = { + Semicolon: { unmodified: ';', shifted: ':' }, + Period: { unmodified: '.', shifted: '>' }, + Comma: { unmodified: ',', shifted: '<' } + } + const period = { key: '≥', code: 'Period', base: '.', windowsVirtualKeyCode: 190 } + let generation = Date.now() + const cdp = await orcaPage.context().newCDPSession(orcaPage) + try { + for (const inputSourceId of [abc, international, abc, pinyin, abc, null, abc]) { + await publishMacKeyboardLayout( + electronApp, + inputSourceId + ? { + inputSourceId, + layoutSourceId: inputSourceId === pinyin ? abc : inputSourceId, + keyCharacters + } + : null, + ++generation + ) + const isAlt = inputSourceId === abc + await waitForPaneOptionAsAlt(orcaPage, isAlt) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsTarget({ pane: 'terminal', repoId: null }) + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + const control = orcaPage.getByRole('radiogroup', { name: 'Option as Alt', exact: true }) + await expect(control.getByRole('radio', { name: 'Auto', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await expect( + orcaPage.getByText( + isAlt + ? /Auto — detected: ABC or U.S. — Option sends Alt\/Esc sequences/ + : inputSourceId + ? /Auto — detected: layout uses Option to compose characters/ + : /Auto — detected: unknown layout — Option composes characters/ + ) + ).toBeVisible() + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await focusActiveTerminalInput(orcaPage) + await clearPtyWriteLog(electronApp) + await pressChromiumOptionPunctuation(cdp, period) + await expect.poll(joinedWrites).toBe(`${isAlt ? '\x1b[46;3u' : '≥'}\x1b[46;3:3u`) + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ) + .toBe('auto') + } + await setMacOptionAsAlt(orcaPage, 'false') + await waitForPaneOptionAsAlt(orcaPage, false) + await clearPtyWriteLog(electronApp) + await pressChromiumOptionPunctuation(cdp, period) + await expect.poll(joinedWrites).toBe('≥\x1b[46;3:3u') + await setMacOptionAsAlt(orcaPage, 'left') + for (const inputSourceId of [international, abc]) { + await publishMacKeyboardLayout( + electronApp, + { inputSourceId, layoutSourceId: inputSourceId, keyCharacters }, + ++generation + ) + for (const side of ['left', 'right'] as const) { + await clearPtyWriteLog(electronApp) + await pressOptionComposedKey(orcaPage, { ...period, side }) + await expect + .poll(joinedWrites) + .toBe(`${side === 'left' ? '\x1b[46;3u' : '≥'}\x1b[46;3:3u`) + } + expect( + await orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ).toBe('left') + } + } finally { + await cdp.detach() + } + }) + + test('configured Option punctuation keeps legacy Alt bytes without enhanced reporting', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 0) + for (const setting of ['true', 'left', 'right'] as const) { + await setMacOptionAsAlt(orcaPage, setting) + await clearPtyWriteLog(electronApp) + const side = setting === 'right' ? 'right' : 'left' + for (const key of [ + { key: '…', code: 'Semicolon' }, + { key: '≥', code: 'Period' }, + { key: '≤', code: 'Comma' } + ]) { + await pressOptionComposedKey(orcaPage, { ...key, side }) + } + await expect.poll(joinedWrites).toBe('\x1b;\x1b.\x1b,') + } + }) + test('types the composed character instead of reporting the physical Alt chord', async ({ orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) @@ -174,7 +245,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { }) test('types a composed character that also needs Shift', async ({ orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) @@ -200,7 +271,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'true') await clearPtyWriteLog(electronApp) @@ -220,7 +291,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'true') await clearPtyWriteLog(electronApp) @@ -241,7 +312,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 5) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 5) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) const letters = [ @@ -278,7 +349,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 29) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 29) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) await pressOptionComposedKey(orcaPage, { key: 'ą', code: 'KeyA' }) @@ -289,7 +360,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }, testInfo) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 5) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 5) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) const cdp = await orcaPage.context().newCDPSession(orcaPage) diff --git a/tests/e2e/terminal-option-key-input.ts b/tests/e2e/terminal-option-key-input.ts new file mode 100644 index 00000000000..a56cfe0065f --- /dev/null +++ b/tests/e2e/terminal-option-key-input.ts @@ -0,0 +1,210 @@ +import type { CDPSession, ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' +import { + execInTerminal, + waitForActiveTerminalManager, + waitForActivePanePtyId +} from './helpers/terminal' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, + readTerminalPtyWrites as getPtyWrites +} from './helpers/terminal-pty-write-spy' +import type { KeyboardLayoutSnapshot } from '../../src/shared/keyboard-layout-snapshot' + +type MacOptionAsAltSetting = 'auto' | 'true' | 'false' | 'left' | 'right' + +export async function setMacOptionAsAlt(page: Page, value: MacOptionAsAltSetting): Promise { + await page.evaluate(async (value) => { + await window.__store?.getState().updateSettings({ terminalMacOptionAsAlt: value }) + }, value) + await expect + .poll( + async () => + page.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt ?? null), + { timeout: 5_000, message: 'terminalMacOptionAsAlt did not apply' } + ) + .toBe(value) +} + +/** Reads the pane's mirrored kitty flags — the exact value the policy consults. */ +async function getPaneKittyKeyboardFlags(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm exposes negotiated flags through either bundled core shape. + const terminal = pane?.terminal as + | { + core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + } + | undefined + return ( + terminal?.core?.coreService?.kittyKeyboard?.flags ?? + terminal?._core?.coreService?.kittyKeyboard?.flags ?? + 0 + ) + }) +} + +/** + * Dispatches the keydown macOS delivers for an Option-composed key: `key` is + * already the composed glyph while `code` still names the physical key. + */ +export async function pressOptionComposedKey( + page: Page, + press: { key: string; code: string; shiftKey?: boolean; side?: 'left' | 'right' } +): Promise<{ keydownDefaultPrevented: boolean }> { + return page.evaluate((press) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const textarea = pane?.container.querySelector('.xterm-helper-textarea') + if (!pane || !textarea) { + throw new Error('No active terminal textarea for the Option chord dispatch') + } + pane.terminal.focus() + textarea.focus() + + // The side-specific setting reads the modifier's location before the chord. + const location = press.side === 'right' ? 2 : 1 + const modifierInit = { + key: 'Alt', + code: location === 2 ? 'AltRight' : 'AltLeft', + altKey: true, + bubbles: true + } + const altDown = new KeyboardEvent('keydown', modifierInit) + Object.defineProperty(altDown, 'location', { get: () => location }) + textarea.dispatchEvent(altDown) + + const keydown = new KeyboardEvent('keydown', { + key: press.key, + code: press.code, + altKey: true, + shiftKey: press.shiftKey === true, + bubbles: true, + cancelable: true + }) + const keyCodes: Record = { Semicolon: 186, Comma: 188, Period: 190 } + const keyCode = keyCodes[press.code] + if (keyCode) { + Object.defineProperty(keydown, 'keyCode', { value: keyCode }) + } + textarea.dispatchEvent(keydown) + + textarea.dispatchEvent( + new KeyboardEvent('keyup', { + key: press.key, + code: press.code, + altKey: true, + shiftKey: press.shiftKey === true, + bubbles: true, + cancelable: true + }) + ) + const altUp = new KeyboardEvent('keyup', modifierInit) + Object.defineProperty(altUp, 'location', { get: () => location }) + textarea.dispatchEvent(altUp) + + return { keydownDefaultPrevented: keydown.defaultPrevented } + }, press) +} + +async function armKittyKeyboardFromPty(page: Page, ptyId: string, flags: number): Promise { + // Why: this is the byte a real kitty-protocol TUI pushes at startup; routing it + // through the PTY exercises the same output-scanning mirror the policy reads. + // `cat` stays in the foreground: flags left armed at exit are grounded by the host. + await execInTerminal(page, ptyId, `printf '\\033[>${flags}u'; cat`) + await expect + .poll(async () => getPaneKittyKeyboardFlags(page), { + timeout: 15_000, + message: 'the pane never mirrored the application kitty keyboard flags' + }) + .toBe(flags) +} + +export async function setUpOptionKeyboardPane( + page: Page, + app: ElectronApplication, + kittyFlags = 1 +): Promise<{ joinedWrites: () => Promise }> { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page) + const ptyId = await waitForActivePanePtyId(page) + await installMainProcessPtyWriteSpy(app) + await armKittyKeyboardFromPty(page, ptyId, kittyFlags) + return { joinedWrites: async () => (await getPtyWrites(app)).join('') } +} + +export async function publishMacKeyboardLayout( + app: ElectronApplication, + snapshot: KeyboardLayoutSnapshot | null, + generation: number +): Promise { + await app.evaluate( + ({ ipcMain, BrowserWindow }, { snapshot, generation }) => { + ipcMain.removeHandler('app:getKeyboardLayoutSnapshot') + ipcMain.handle('app:getKeyboardLayoutSnapshot', () => snapshot) + ipcMain.removeHandler('app:getKeyboardInputSourceId') + ipcMain.handle('app:getKeyboardInputSourceId', () => snapshot?.inputSourceId ?? null) + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('app:keyboardLayoutChanged', { phase: 'refresh', generation }) + } + }, + { snapshot, generation } + ) +} + +export async function waitForPaneOptionAsAlt(page: Page, expected: boolean): Promise { + await expect + .poll(() => + page.evaluate(() => { + const state = window.__store?.getState() + const tabId = state?.activeTabId + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.terminal.options.macOptionIsMeta + }) + ) + .toBe(expected) +} + +export async function pressChromiumOptionPunctuation( + cdp: CDPSession, + key: { key: string; code: string; base: string; windowsVirtualKeyCode: number } +): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: key.key, + code: key.code, + modifiers: 1, + text: key.key, + unmodifiedText: key.base, + windowsVirtualKeyCode: key.windowsVirtualKeyCode + }) + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: key.key, + code: key.code, + modifiers: 1, + windowsVirtualKeyCode: key.windowsVirtualKeyCode + }) +} From 5f308bfa9c796f33e17c9e1cff180a2247450d0a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 00:52:32 -0700 Subject: [PATCH 16/26] revert: take the 26 Phase 3 (#16741 port) PRs back out of main (#24559) * Revert "feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519)" This reverts commit 783101b3045848d5f9b5eeefe4a699487310521e. * Revert "feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)" This reverts commit 38c2d1dcb9dc079bba6f59bdaa179d6af0584f09. * Revert "feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) (#24453)" This reverts commit 8b76683b400911b6348623d60e29f8ff5e21cbe7. * Revert "fix(ssh): orcad GC honors the activation journal; readiness requires proven daemon coverage (#16741 T6 follow-up) (#24451)" This reverts commit d3f8c5063b1520cf78b0e2fbd3cb5a4d9330acd1. * Revert "feat(ssh): remote orcad stop by request file and journaled decommission (#16741 T6-4) (#24449)" This reverts commit 43d9b43d3f6e0255348cf2771b5af3e9fcf3e5b0. * Revert "feat(orcad): supervisable server: stop requests, managed stop receipts and a lifetime that keeps its lock on failed teardown (#16741 T6-3) (#24433)" This reverts commit b093d3ab201d8700dc173c2f418f74c8758b61b6. * Revert "feat(ssh): crash-safe orcad activation, rollback and recovery (#16741 T6-2) (#24423)" This reverts commit 1a9ac0e955b87af454a225d9615a56c1bb753603. * Revert "feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)" This reverts commit 99db2bfae415fe2e5199e7b559853d211bfdbe83. * Revert "feat(relay): capability-gated owner reset with a durable preparation journal (#16741 T3 R1) (#24418)" This reverts commit 34a582bd399de814f3f0a7c47aa3e394d13bc773. * Revert "feat(ssh): track connection-manager drains, test probes and provider continuations (#16741 T2 P3+P8a) (#24407)" This reverts commit d53063d2b1641145d78453408235f58d87e58198. * Revert "feat(daemon): idle retirement, session census and recovery-only provider (#16741 T2 P4b) (#24409)" This reverts commit ff212dbbeffebe7d947763ee0bbb0d5ef00bd40c. * Revert "feat(ssh): add pty.resumeClient and split SSH PTY process listing (#16741 T2 P5+P6) (#24414)" This reverts commit 92cb71765ed5094fefe0d6a2f29635b3cebafc61. * Revert "feat(relay): await owned watcher and agent children on shutdown (#16741 T2 P1) (#24400)" This reverts commit 6b36e4f85bf9ea80cced1e7975cc79b80d0b5c4c. * Revert "feat(session): retry failed renderer session writes and verify local folder PTYs (#16741 T2 P9) (#24406)" This reverts commit d23ecef30118bf7ccd297d7d1aad37d516a67d47. * Revert "feat(ssh): remote orcad primitives on the pinned Node runtime (#16741 T6-1) (#24419)" This reverts commit dd87ae578d12dc06e54244ac35fa7ae6546ae668. * Revert "fix(runtime): fence runtime-environment subscriptions and status probes by identity (#16741 T5-3) (#24421)" This reverts commit ece9e4d2e31b65719c6800208439574189251dc8. * Revert "feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) (#24422)" This reverts commit 3fbdaba262138e9f8c46db92d87b06b929c11e13. * Revert "feat(ssh): wire SshConnection through the work and transport close ledgers (#16741 T2 P2) (#24401)" This reverts commit 4e8edc887242366d97fc42705e1fab3c87f97aa1. * Revert "feat(profiles): carry markdown frontmatter visibility in project transfers (#16741 T2 P7) (#24405)" This reverts commit 60c93263ccea841249c83b8ec65a6ee9fb0f6049. * Revert "fix(runtime): project the PTY incarnation onto mobile session tabs (#24413)" This reverts commit 99e030357226f76a6aed7514b81a951a10a7de6b. * Revert "feat(daemon): tag daemon stream data with the PTY incarnation id (#16741 T2 P4a) (#24402)" This reverts commit 817af768b01b296800e5850654493491e5aadfe6. * Revert "feat(ssh): port the SSH connection work ledger and transport close ledger (#16741 T2) (#24210)" This reverts commit c9918931c8e6fa23e311814eda9ca63589fe45a8. * Revert "feat(relay): fence and drain file and git response streams on shutdown (#24185)" This reverts commit dc08ffeba9603c5b48b9b100d0a7f89b021e6d1b. * Revert "refactor(runtime-rpc): extract the Node WebSocket lifecycle; opt-in pinned port (#24186)" This reverts commit a789233bbb14f872fc0d51c2058ff324a14ac43c. * Revert "feat(relay): route relay handlers through work admission; producer publication drain (#24181)" This reverts commit 0b812bd69856f9f2337dd54d7f3b9ceedff24cc9. * Revert "feat(relay): land the #16741 T1 seam (work drain, publication drain, release gate) (#24156)" This reverts commit 3aa2d3af7cbaa556b6c2e642d545f0ffdb53a0ef. --------- Co-authored-by: m4air --- .../orcad-daemon-protocol-crossing.test.mjs | 100 --- docs/reference/orcad-operations.md | 41 - src/main/daemon/daemon-health-identity.ts | 13 - src/main/daemon/daemon-health.test.ts | 54 +- src/main/daemon/daemon-health.ts | 77 +- src/main/daemon/daemon-idle-shutdown.test.ts | 29 - src/main/daemon/daemon-init.ts | 3 - .../daemon/daemon-provider-census.test.ts | 54 -- src/main/daemon/daemon-provider-state.ts | 97 +-- .../daemon/daemon-pty-event-subscriptions.ts | 76 +- .../daemon/daemon-pty-process-inspection.ts | 2 +- .../daemon/daemon-pty-router-test-fixture.ts | 168 ---- src/main/daemon/daemon-pty-router.test.ts | 240 ++++-- src/main/daemon/daemon-pty-router.ts | 75 +- src/main/daemon/daemon-pty-runtime-state.ts | 14 +- src/main/daemon/daemon-pty-session-spawn.ts | 9 - .../daemon-recovery-only-adapter.test.ts | 103 --- .../daemon/daemon-recovery-provider-init.ts | 10 - .../daemon/daemon-recovery-provider.test.ts | 54 -- src/main/daemon/daemon-recovery-provider.ts | 41 - .../daemon/daemon-recovery-spawn-admission.ts | 21 - src/main/daemon/daemon-request-router.ts | 3 +- .../daemon/daemon-router-retirement.test.ts | 64 -- src/main/daemon/daemon-router-retirement.ts | 87 --- .../daemon-router-session-reconciliation.ts | 37 - .../daemon/daemon-stream-data-batcher.test.ts | 14 - src/main/daemon/daemon-stream-data-batcher.ts | 3 +- src/main/daemon/daemon-stream-data-entry.ts | 11 +- .../daemon/daemon-stream-data-split.test.ts | 41 +- src/main/daemon/daemon-stream-data-split.ts | 58 +- src/main/daemon/daemon-stream-events.ts | 1 - .../daemon/daemon-stream-incarnation.test.ts | 59 -- .../daemon/daemon-stream-keep-tail-drop.ts | 2 - src/main/daemon/daemon-terminal-admission.ts | 41 +- src/main/daemon/session-output-pipeline.ts | 3 - src/main/daemon/session-output-plane.ts | 35 +- src/main/daemon/session.ts | 20 +- .../daemon/terminal-host-create-contract.ts | 7 - src/main/durable-file-write.ts | 13 +- .../filesystem-import-ssh-remote-existence.ts | 29 - src/main/ipc/filesystem-import-ssh.ts | 36 +- .../filesystem-provider-continuations.test.ts | 238 ------ .../filesystem/filesystem-write-handlers.ts | 9 +- .../orcad-runtime-lifecycle-handlers.test.ts | 66 -- .../ipc/orcad-runtime-lifecycle-handlers.ts | 37 - ...orcad-runtime-maintenance-handlers.test.ts | 87 --- .../ipc/orcad-runtime-maintenance-handlers.ts | 86 --- .../orcad-ssh-provisioning-handlers.test.ts | 40 - .../ipc/orcad-ssh-provisioning-handlers.ts | 20 - .../ipc/parcel-watcher-child-termination.ts | 27 +- src/main/ipc/parcel-watcher-entry-path.ts | 8 - .../ipc/parcel-watcher-owned-children.test.ts | 120 --- src/main/ipc/parcel-watcher-owned-children.ts | 47 -- .../parcel-watcher-process-dispose.test.ts | 48 -- .../ipc/parcel-watcher-process-entry.test.ts | 60 +- src/main/ipc/parcel-watcher-process-entry.ts | 2 +- .../ipc/parcel-watcher-process-supervisor.ts | 18 +- src/main/ipc/parcel-watcher-process.ts | 14 - ...parcel-watcher-supervisor-disposal.test.ts | 67 -- .../ipc/parcel-watcher-supervisor-disposal.ts | 5 +- ...runtime-environment-capability-evidence.ts | 4 - ...ntime-environment-connectivity-handlers.ts | 20 +- .../runtime-environment-handler-channels.ts | 14 +- .../ipc/runtime-environment-managed-tunnel.ts | 11 - .../runtime-environment-removal-cleanup.ts | 27 - .../ipc/runtime-environment-revision-guard.ts | 8 - .../ipc/runtime-environment-status-owner.ts | 27 +- .../ipc/runtime-environment-status-probe.ts | 42 - ...time-environment-status-retirement.test.ts | 110 --- ...-environment-subscription-identity.test.ts | 108 --- .../ipc/runtime-environment-subscriptions.ts | 268 ------- .../runtime-environment-support-routing.ts | 31 +- .../runtime-environment-transport-routing.ts | 54 +- .../ipc/runtime-environments-pairing.test.ts | 24 - ...environments-subscription-teardown.test.ts | 9 +- src/main/ipc/runtime-environments.ts | 251 +++++- .../ipc/runtime-ssh-access-handlers.test.ts | 89 --- src/main/ipc/runtime-ssh-access-handlers.ts | 24 - .../runtime-watcher-disposal-owners.test.ts | 103 --- .../ipc/runtime-watcher-disposal-owners.ts | 90 --- .../ipc/runtime-watcher-pool-shutdown.test.ts | 79 -- src/main/ipc/runtime-watcher-pool-state.ts | 10 +- src/main/ipc/runtime-watcher-process-pool.ts | 33 +- src/main/ipc/ssh-active-relay-sessions.ts | 7 +- src/main/ipc/ssh-connect-attempt-registry.ts | 41 - src/main/ipc/ssh-connection-handlers.ts | 14 +- src/main/ipc/ssh-host-sleep-reconnect.test.ts | 24 - src/main/ipc/ssh-host-sleep-reconnect.ts | 16 +- src/main/ipc/ssh-ipc-module-mocks.ts | 1 - src/main/ipc/ssh-renderer-broadcast.ts | 7 +- src/main/ipc/ssh-shutdown-drain.ts | 39 +- .../ipc/ssh-state-broadcast-fanout.test.ts | 35 - ...ssh-target-crud-handlers-ownership.test.ts | 74 -- src/main/ipc/ssh-target-crud-handlers.ts | 17 +- src/main/ipc/ssh-target-lifecycle-queue.ts | 24 +- ...ssh-test-connection-probe-registry.test.ts | 98 --- src/main/ipc/ssh-test-probe-presence.test.ts | 36 - src/main/ipc/ssh.ts | 5 +- .../worktrees-ssh-provider-authority.test.ts | 94 --- .../register-detected-worktree-handlers.ts | 50 +- .../register-worktree-removal-handlers.ts | 13 +- .../worktree-removal-continuations.test.ts | 98 --- ...cal-pty-registry-folder-workspaces.test.ts | 87 --- .../memory/hydrate-local-pty-registry.test.ts | 12 +- src/main/memory/hydrate-local-pty-registry.ts | 20 +- .../verified-local-folder-workspaces.test.ts | 121 --- .../verified-local-folder-workspaces.ts | 40 - ...-project-session-field-disposition.test.ts | 114 --- ...ofile-project-session-field-disposition.ts | 5 +- .../profile-project-session-state.ts | 23 - .../profile-project-session-transfer.ts | 229 +++++- .../profile-session-markdown-transfer.ts | 88 --- .../profile-session-owner-transfer.test.ts | 87 --- .../profile-session-owner-transfer.ts | 274 ------- src/main/orcad-migration-export-holds.test.ts | 152 ---- .../electron-serve-browser-process.test.ts | 36 - .../orcad/electron-serve-browser-process.ts | 8 +- .../external-chromium-browser-process.test.ts | 46 -- .../external-chromium-browser-process.ts | 6 +- .../external-chromium-browser-session.test.ts | 59 +- .../external-chromium-browser-session.ts | 33 +- src/main/orcad/main-preflight-order.test.ts | 21 - src/main/orcad/main.ts | 60 +- src/main/orcad/orcad-browser-provider.ts | 59 +- ...orcad-browser-startup-cancellation.test.ts | 62 -- src/main/orcad/orcad-browser-startup.test.ts | 103 --- src/main/orcad/orcad-browser-startup.ts | 95 --- .../orcad/orcad-completed-stop-receipt.ts | 109 --- .../orcad/orcad-daemon-retirement.test.ts | 72 -- src/main/orcad/orcad-daemon-retirement.ts | 97 --- src/main/orcad/orcad-entry.ts | 71 +- src/main/orcad/orcad-health.test.ts | 18 +- src/main/orcad/orcad-health.ts | 23 +- src/main/orcad/orcad-instance-lock.test.ts | 91 +-- src/main/orcad/orcad-instance-lock.ts | 108 +-- src/main/orcad/orcad-lifecycle-host.test.ts | 71 -- src/main/orcad/orcad-lifecycle.ts | 68 +- .../orcad/orcad-managed-stop-admission.ts | 30 - .../orcad-managed-stop-cancellation.test.ts | 122 --- .../orcad/orcad-managed-stop-cancellation.ts | 34 - src/main/orcad/orcad-managed-stop-command.ts | 88 --- .../orcad/orcad-managed-stop-completion.ts | 129 ---- src/main/orcad/orcad-managed-stop-decision.ts | 64 -- src/main/orcad/orcad-managed-stop-request.ts | 66 -- src/main/orcad/orcad-managed-stop.test.ts | 254 ------ .../orcad/orcad-migration-manifest-digest.ts | 18 - src/main/orcad/orcad-push-startup.test.ts | 66 +- src/main/orcad/orcad-runtime-lifetime.test.ts | 81 -- src/main/orcad/orcad-runtime-lifetime.ts | 42 - .../orcad/orcad-stop-request-listener.test.ts | 143 ---- src/main/orcad/orcad-stop-request-listener.ts | 144 ---- src/main/orcad/orcad-terminal-census.test.ts | 51 -- src/main/orcad/orcad-terminal-census.ts | 39 - .../session-snapshot-operations.ts | 1 - .../loading-store/store-domain-composition.ts | 12 +- .../loading-store/store-runtime-state.ts | 2 - .../loading-store/terminal-session-cleanup.ts | 6 +- .../workspace-session-snapshot-publication.ts | 20 +- .../orcad-destination-catalog-projection.ts | 9 - ...orcad-scrollback-snapshot-transfer.test.ts | 231 ------ .../orcad-scrollback-snapshot-transfer.ts | 302 -------- .../orcad-source-automation-state.ts | 187 ----- .../orcad-source-catalog.ts | 57 -- .../orcad-source-client-browser-intents.ts | 73 -- .../orcad-source-client-state.test.ts | 282 ------- .../orcad-source-client-state.ts | 258 ------- .../orcad-source-dependency-census.test.ts | 320 -------- .../orcad-source-dependency-census.ts | 257 ------- .../orcad-source-dormant-state.ts | 273 ------- .../orcad-source-export.test.ts | 127 --- .../orcad-source-export.ts | 128 ---- .../orcad-source-retired-worktree-names.ts | 81 -- .../orcad-source-scope.test.ts | 58 -- .../orcad-source-scope.ts | 60 -- .../orcad-source-scrollback-state.test.ts | 39 - .../orcad-source-scrollback-state.ts | 158 ---- .../orcad-source-session-dependencies.ts | 145 ---- ...ad-source-workspace-session-eligibility.ts | 245 ------ ...rcad-source-workspace-session-fragments.ts | 113 --- .../orcad-source-workspace-session-layout.ts | 64 -- .../orcad-source-workspace-session.ts | 115 --- .../orcad-source-worktree-metadata.ts | 122 --- src/main/providers/ssh-filesystem-dispatch.ts | 10 - src/main/providers/ssh-git-dispatch.ts | 15 - ...y-notification-recovery-activation.test.ts | 326 -------- ...-pty-notification-routing-recovery.test.ts | 281 ------- ...h-pty-notification-routing-test-fixture.ts | 94 --- .../ssh-pty-notification-routing.test.ts | 393 +++++++++- src/main/providers/ssh-pty-process-list.ts | 52 -- src/main/providers/ssh-pty-provider.ts | 31 +- src/main/providers/ssh-pty-spawn-env.test.ts | 25 - .../orca-runtime-automation-operations.ts | 4 - ...ca-runtime-headless-terminal-close.test.ts | 48 -- ...runtime-stop-explicitly-closed-tab-ptys.ts | 34 +- src/main/runtime/rpc/methods/index.ts | 2 - .../rpc/methods/orcad-terminal-census.ts | 18 - .../runtime/rpc/node-websocket-lifecycle.ts | 153 ---- .../runtime/rpc/websocket-transport-limits.ts | 5 - src/main/runtime/rpc/ws-transport.test.ts | 41 +- src/main/runtime/rpc/ws-transport.ts | 189 +++-- ...ntime-environment-identity-verification.ts | 47 -- ...me-legacy-worker-recovery-lifetime.test.ts | 124 --- ...acy-worker-terminal-recovery-controller.ts | 27 +- ...-legacy-worker-terminal-recovery-runner.ts | 8 +- ...ile-session-incarnation-projection.test.ts | 65 -- .../runtime-mobile-session-projection.ts | 19 +- .../runtime-rpc-required-ws-port.test.ts | 83 -- .../runtime-rpc/runtime-rpc-lifecycle.ts | 9 +- .../runtime-rpc/runtime-rpc-pairing-types.ts | 2 - .../runtime/runtime-rpc/runtime-rpc-state.ts | 3 - .../orcad-activation-crash-recovery.test.ts | 233 ------ .../ssh/orcad-activation-host-test-harness.ts | 277 ------- src/main/ssh/orcad-activation-lock.ts | 154 ---- src/main/ssh/orcad-activation-record-store.ts | 60 +- src/main/ssh/orcad-activation-record.ts | 13 - src/main/ssh/orcad-activation-recovery.ts | 136 ---- .../orcad-activation-transaction-schema.ts | 98 --- .../ssh/orcad-activation-transaction-store.ts | 55 -- ...rcad-activation-transaction-transitions.ts | 102 --- .../ssh/orcad-activation-transaction.test.ts | 228 ------ src/main/ssh/orcad-activation-transaction.ts | 260 ------- src/main/ssh/orcad-active-readiness.ts | 114 --- .../ssh/orcad-daemon-protocol-crossing.ts | 56 -- src/main/ssh/orcad-decommission-recovery.ts | 70 -- src/main/ssh/orcad-decommission-stop.ts | 75 -- .../orcad-decommission-transaction.test.ts | 93 --- .../ssh/orcad-decommission-transaction.ts | 114 --- src/main/ssh/orcad-gc-transaction-pins.ts | 55 -- src/main/ssh/orcad-incumbent-recovery.ts | 153 ---- .../ssh/orcad-initial-activation-admission.ts | 92 --- src/main/ssh/orcad-installed-activation.ts | 305 -------- .../orcad-managed-lifecycle-test-fixture.ts | 103 --- src/main/ssh/orcad-managed-remote-stop.ts | 128 ---- src/main/ssh/orcad-managed-runtime-context.ts | 113 --- src/main/ssh/orcad-managed-tunnel-resume.ts | 270 ------- src/main/ssh/orcad-managed-tunnel.test.ts | 504 ------------ src/main/ssh/orcad-managed-tunnel.ts | 295 ------- .../ssh/orcad-managed-update-deferrals.ts | 22 - .../orcad-migration-manifest-export.test.ts | 168 ---- .../ssh/orcad-migration-manifest-export.ts | 77 -- ...cad-migration-snapshot-coordinator.test.ts | 171 ----- .../orcad-migration-snapshot-coordinator.ts | 124 --- src/main/ssh/orcad-recovery-slot.ts | 193 ----- src/main/ssh/orcad-remote-build-hash.ts | 36 - src/main/ssh/orcad-remote-context.ts | 59 -- .../ssh/orcad-remote-decommission.test.ts | 163 ---- src/main/ssh/orcad-remote-deploy-stop.ts | 68 ++ src/main/ssh/orcad-remote-deploy.test.ts | 54 +- src/main/ssh/orcad-remote-deploy.ts | 278 ++++++- src/main/ssh/orcad-remote-gc.test.ts | 72 +- src/main/ssh/orcad-remote-gc.ts | 14 +- src/main/ssh/orcad-remote-launch.ts | 22 +- src/main/ssh/orcad-remote-primitives.test.ts | 243 ------ src/main/ssh/orcad-remote-process-control.ts | 26 +- src/main/ssh/orcad-remote-record-file.ts | 68 -- src/main/ssh/orcad-remote-rollback.test.ts | 146 ++-- src/main/ssh/orcad-remote-rollback.ts | 253 ++++-- src/main/ssh/orcad-remote-runtime-control.ts | 63 -- ...-remote-shell-commands.integration.test.ts | 41 +- src/main/ssh/orcad-remote-stop.ts | 143 ---- src/main/ssh/orcad-rollback-transition.ts | 281 ------- .../ssh/orcad-runtime-decommission.test.ts | 216 ------ src/main/ssh/orcad-runtime-decommission.ts | 205 ----- src/main/ssh/orcad-runtime-deployment.test.ts | 367 --------- src/main/ssh/orcad-runtime-deployment.ts | 152 ---- src/main/ssh/orcad-runtime-lifecycle.ts | 8 - .../ssh/orcad-runtime-maintenance.test.ts | 197 ----- src/main/ssh/orcad-runtime-maintenance.ts | 215 ------ src/main/ssh/orcad-runtime-status.ts | 68 -- src/main/ssh/orcad-ssh-provisioning.test.ts | 245 ------ src/main/ssh/orcad-ssh-provisioning.ts | 200 ----- .../ssh/orcad-state-snapshot-shell.test.ts | 88 --- src/main/ssh/orcad-state-snapshot.test.ts | 20 +- src/main/ssh/orcad-state-snapshot.ts | 60 +- .../ssh/orcad-terminal-census-client.test.ts | 101 --- src/main/ssh/orcad-terminal-census-client.ts | 82 -- src/main/ssh/orcad-tunneled-pairing.test.ts | 48 -- src/main/ssh/orcad-tunneled-pairing.ts | 25 - src/main/ssh/orcad-update-plan.test.ts | 115 +-- src/main/ssh/orcad-update-plan.ts | 83 +- .../runtime-ssh-access-verification.test.ts | 143 ---- .../ssh/runtime-ssh-access-verification.ts | 23 - src/main/ssh/runtime-ssh-access.test.ts | 378 --------- src/main/ssh/runtime-ssh-access.ts | 249 ------ src/main/ssh/ssh-channel-open.ts | 204 ----- .../ssh/ssh-connection-channel-error.test.ts | 63 -- .../ssh-connection-channel-lifetime.test.ts | 102 --- .../ssh/ssh-connection-channel-lifetime.ts | 82 -- .../ssh/ssh-connection-channel-open.test.ts | 12 +- src/main/ssh/ssh-connection-close-drain.ts | 34 - ...ssh-connection-destination-capture.test.ts | 123 --- .../ssh/ssh-connection-destination.test.ts | 57 -- src/main/ssh/ssh-connection-destination.ts | 74 -- .../ssh/ssh-connection-direct-startup.test.ts | 8 +- .../ssh-connection-disconnect-drain.test.ts | 269 ------- .../ssh/ssh-connection-file-transfer.test.ts | 7 +- src/main/ssh/ssh-connection-file-transfers.ts | 219 ------ ...h-connection-host-key-store-wiring.test.ts | 5 +- .../ssh-connection-manager-closure.test.ts | 108 --- .../ssh-connection-manager-registry.test.ts | 53 -- .../ssh/ssh-connection-manager-test-probes.ts | 43 -- src/main/ssh/ssh-connection-manager.test.ts | 50 -- src/main/ssh/ssh-connection-manager.ts | 137 +--- .../ssh-connection-reconnect-ladder.test.ts | 35 - ...h-connection-store-orcad-ownership.test.ts | 72 -- .../ssh/ssh-connection-store-test-fixture.ts | 107 --- src/main/ssh/ssh-connection-store.test.ts | 69 +- src/main/ssh/ssh-connection-store.ts | 9 +- .../ssh-connection-system-work-drain.test.ts | 100 --- src/main/ssh/ssh-connection-test-fixtures.ts | 9 +- .../ssh-connection-transport-closure.test.ts | 179 ----- .../ssh/ssh-connection-transport-closure.ts | 33 - .../ssh/ssh-connection-work-drain.test.ts | 244 ------ .../ssh/ssh-connection-work-ledger.test.ts | 256 ------- src/main/ssh/ssh-connection-work-ledger.ts | 208 ----- src/main/ssh/ssh-connection.ts | 721 +++++++++--------- .../ssh/ssh-forward-channel-lifetime.test.ts | 102 --- src/main/ssh/ssh-forward-channel-lifetime.ts | 78 -- .../ssh/ssh-forward-socket-admission.test.ts | 35 - .../ssh-multi-factor-authentication.test.ts | 31 +- src/main/ssh/ssh-provider-continuations.ts | 24 - .../ssh-pty-consumer-resume-session.test.ts | 213 ------ src/main/ssh/ssh-pty-consumer-session.ts | 84 +- src/main/ssh/ssh-relay-exec-command.ts | 2 +- src/main/ssh/ssh-relay-exec-input.test.ts | 57 -- src/main/ssh/ssh-relay-install-lock.test.ts | 27 +- src/main/ssh/ssh-relay-install-lock.ts | 30 +- .../ssh-system-transport.integration.test.ts | 6 +- src/main/ssh/ssh-target-orcad-claims.test.ts | 149 ---- src/main/ssh/ssh-target-orcad-claims.ts | 182 ----- .../ssh-target-orcad-dependents-fixture.ts | 16 - .../ssh/ssh-target-orcad-dependents.test.ts | 136 ---- src/main/ssh/ssh-target-orcad-dependents.ts | 151 ---- .../ssh/ssh-target-orcad-preflight.test.ts | 159 ---- src/main/ssh/ssh-target-orcad-preflight.ts | 79 -- src/main/ssh/ssh-target-registry.ts | 12 - .../ssh/ssh-transport-close-ledger.test.ts | 69 -- src/main/ssh/ssh-transport-close-ledger.ts | 39 - .../ssh-transport-close-observation.test.ts | 33 - .../ssh/ssh-transport-close-observation.ts | 25 - .../ssh/ssh-upload-session-lifetime.test.ts | 193 ----- src/main/ssh/ssh-upload-session-lifetime.ts | 33 - src/main/ssh/system-ssh-command.ts | 4 - .../headless-pty-hydration-ordering.test.ts | 7 +- ...nal-scrollback-snapshot-async-migration.ts | 5 +- src/main/terminal-scrollback-snapshots.ts | 37 +- src/main/worktree-retirement-namespace.ts | 2 +- src/relay/agent-exec-disposal.test.ts | 148 ---- src/relay/agent-exec-handler.test.ts | 4 - src/relay/agent-exec-handler.ts | 8 - src/relay/dispatcher-client-state.ts | 21 +- src/relay/dispatcher-client-writer.ts | 4 - src/relay/dispatcher-contract.ts | 2 - src/relay/dispatcher-frame-codec.ts | 12 +- .../dispatcher-notification-publication.ts | 47 +- .../dispatcher-producer-settlement.test.ts | 106 --- src/relay/dispatcher-producer-transport.ts | 25 +- src/relay/dispatcher-rpc-routing.ts | 2 - src/relay/dispatcher-work-drain.test.ts | 184 ----- src/relay/fs-file-stream-shutdown.test.ts | 114 --- src/relay/fs-handler-file-read.ts | 95 +-- src/relay/fs-handler-stream.test.ts | 23 - src/relay/fs-handler-utils.ts | 8 +- src/relay/fs-handler.ts | 12 +- src/relay/fs-stream-registry-shutdown.test.ts | 171 ----- src/relay/fs-stream-registry.ts | 86 +-- src/relay/fs-stream-terminal-frame-slots.ts | 51 -- .../git-response-stream-shutdown.test.ts | 176 ----- src/relay/git-response-stream.ts | 26 +- src/relay/relay-agent-process-lifetime.ts | 76 -- src/relay/relay-daemon.ts | 82 +- src/relay/relay-filesystem-watch-registry.ts | 91 +-- src/relay/relay-grace-lifecycle.test.ts | 152 ---- src/relay/relay-grace-lifecycle.ts | 93 +-- src/relay/relay-owner-reset-dispatch.test.ts | 168 ---- ...ay-owner-reset-preparation-journal.test.ts | 134 ---- .../relay-owner-reset-preparation-journal.ts | 113 --- .../relay-owner-reset-registration.test.ts | 188 ----- src/relay/relay-owner-reset-registration.ts | 75 -- src/relay/relay-owner-reset.test.ts | 377 --------- src/relay/relay-owner-reset.ts | 224 ------ .../relay-producer-publication-drain.test.ts | 188 ----- src/relay/relay-producer-publication-drain.ts | 50 -- ...set-preparation-reader.integration.test.ts | 98 --- .../relay-reset-preparation-reader.test.ts | 104 --- src/relay/relay-reset-preparation-reader.ts | 76 -- ...lay-runtime-owned-process-shutdown.test.ts | 91 --- src/relay/relay-runtime-services.ts | 31 +- src/relay/relay-watcher-process-pool.ts | 3 +- src/relay/relay-watcher-setup-wait.ts | 50 -- src/relay/relay-watcher-shutdown.test.ts | 143 ---- src/relay/relay-watcher-shutdown.ts | 57 -- src/relay/relay-watcher-teardown-tracker.ts | 2 +- src/relay/relay-work-admission.test.ts | 223 ------ src/relay/relay-work-admission.ts | 82 -- src/relay/relay.ts | 9 - .../ssh-pty-consumer-resume-client.test.ts | 131 ---- .../ssh-pty-consumer-session-adapter.test.ts | 63 +- src/relay/ssh-pty-consumer-session-adapter.ts | 67 +- .../app-shell/use-app-session-persistence.ts | 3 +- ...on-write-subscriber-acknowledgment.test.ts | 129 ---- ...ession-write-subscriber-allocation.test.ts | 8 +- .../src/lib/session-write-subscriber.ts | 47 +- ...ssion-split-pane-retirement-fences.test.ts | 157 ---- src/shared/managed-orcad-ssh-owner.test.ts | 26 - src/shared/managed-orcad-ssh-owner.ts | 29 - src/shared/orcad-decommission.ts | 19 - src/shared/orcad-managed-runtime.ts | 99 --- src/shared/orcad-migration-catalog-state.ts | 92 --- .../orcad-migration-client-state-parsing.ts | 239 ------ ...migration-client-state-value-validation.ts | 80 -- src/shared/orcad-migration-client-state.ts | 149 ---- ...cad-migration-dormant-automation-fields.ts | 101 --- ...migration-dormant-automation-validation.ts | 240 ------ ...gration-dormant-session-validation.test.ts | 177 ----- ...ad-migration-dormant-session-validation.ts | 271 ------- ...igration-dormant-state-entry-validation.ts | 203 ----- ...rcad-migration-dormant-state-validation.ts | 184 ----- ...rcad-migration-dormant-value-validation.ts | 100 --- .../orcad-migration-manifest-contract.test.ts | 124 --- src/shared/orcad-migration-manifest-fields.ts | 62 -- .../orcad-migration-manifest-validation.ts | 277 ------- src/shared/orcad-migration-manifest.test.ts | 378 --------- src/shared/orcad-migration-manifest.ts | 180 ----- src/shared/orcad-migration-preflight.ts | 90 --- src/shared/orcad-migration-scrollback.test.ts | 134 ---- src/shared/orcad-migration-scrollback.ts | 187 ----- ...igration-staged-catalog-validation.test.ts | 43 -- ...cad-migration-staged-catalog-validation.ts | 55 -- src/shared/orcad-ssh-provisioning.ts | 20 - src/shared/orcad-stop-request.ts | 108 --- src/shared/orcad-terminal-census.ts | 27 - src/shared/protocol-version.ts | 5 - src/shared/pty-consumer-session-contract.ts | 1 - .../pty-consumer-session-resume-only.test.ts | 239 ------ src/shared/pty-consumer-session.ts | 31 - ...ty-ownership-transfer-release-gate.test.ts | 29 - .../pty-ownership-transfer-release-gate.ts | 12 - src/shared/relay-owner-reset-contract.test.ts | 25 - src/shared/relay-owner-reset-contract.ts | 94 --- .../relay-reset-preparation-contract.test.ts | 90 --- .../relay-reset-preparation-contract.ts | 118 --- src/shared/relay-work-drain-contract.ts | 33 - .../rpc-params-catalog.generated.ts | 2 - src/shared/runtime-bootstrap.ts | 2 +- .../runtime-environment-authority-binding.ts | 19 - ...me-environment-managed-orcad-store.test.ts | 133 ---- ...runtime-environment-managed-orcad-store.ts | 161 ---- ...ntime-environment-reconciliation-record.ts | 30 - ...ntime-environment-shipped-store-fixture.ts | 35 - .../runtime-environment-sidecar.test.ts | 151 ---- src/shared/runtime-environment-sidecar.ts | 197 ----- ...ntime-environment-ssh-access-store.test.ts | 325 -------- .../runtime-environment-ssh-access-store.ts | 307 -------- src/shared/runtime-environment-store-file.ts | 109 --- src/shared/runtime-environment-store.ts | 131 ++-- .../runtime-environments-ssh-access.test.ts | 137 ---- src/shared/runtime-environments.ts | 132 +--- .../runtime-rpc-call-queue-retirement.test.ts | 65 -- src/shared/runtime-rpc-call-queue.ts | 45 +- src/shared/runtime-ssh-access.ts | 15 - src/shared/ssh-types.ts | 5 +- .../transport-publication-drain.test.ts | 70 -- src/shared/transport-publication-drain.ts | 87 --- ...workspace-session-terminal-buffers.test.ts | 36 +- .../workspace-session-terminal-buffers.ts | 15 +- 466 files changed, 3231 insertions(+), 38965 deletions(-) delete mode 100644 config/scripts/orcad-daemon-protocol-crossing.test.mjs delete mode 100644 src/main/daemon/daemon-health-identity.ts delete mode 100644 src/main/daemon/daemon-provider-census.test.ts delete mode 100644 src/main/daemon/daemon-pty-router-test-fixture.ts delete mode 100644 src/main/daemon/daemon-recovery-only-adapter.test.ts delete mode 100644 src/main/daemon/daemon-recovery-provider-init.ts delete mode 100644 src/main/daemon/daemon-recovery-provider.test.ts delete mode 100644 src/main/daemon/daemon-recovery-provider.ts delete mode 100644 src/main/daemon/daemon-recovery-spawn-admission.ts delete mode 100644 src/main/daemon/daemon-router-retirement.test.ts delete mode 100644 src/main/daemon/daemon-router-retirement.ts delete mode 100644 src/main/daemon/daemon-router-session-reconciliation.ts delete mode 100644 src/main/daemon/daemon-stream-incarnation.test.ts delete mode 100644 src/main/ipc/filesystem-import-ssh-remote-existence.ts delete mode 100644 src/main/ipc/filesystem-provider-continuations.test.ts delete mode 100644 src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts delete mode 100644 src/main/ipc/orcad-runtime-lifecycle-handlers.ts delete mode 100644 src/main/ipc/orcad-runtime-maintenance-handlers.test.ts delete mode 100644 src/main/ipc/orcad-runtime-maintenance-handlers.ts delete mode 100644 src/main/ipc/orcad-ssh-provisioning-handlers.test.ts delete mode 100644 src/main/ipc/orcad-ssh-provisioning-handlers.ts delete mode 100644 src/main/ipc/parcel-watcher-owned-children.test.ts delete mode 100644 src/main/ipc/parcel-watcher-owned-children.ts delete mode 100644 src/main/ipc/parcel-watcher-process-dispose.test.ts delete mode 100644 src/main/ipc/parcel-watcher-supervisor-disposal.test.ts delete mode 100644 src/main/ipc/runtime-environment-managed-tunnel.ts delete mode 100644 src/main/ipc/runtime-environment-removal-cleanup.ts delete mode 100644 src/main/ipc/runtime-environment-status-probe.ts delete mode 100644 src/main/ipc/runtime-environment-status-retirement.test.ts delete mode 100644 src/main/ipc/runtime-environment-subscription-identity.test.ts delete mode 100644 src/main/ipc/runtime-environment-subscriptions.ts delete mode 100644 src/main/ipc/runtime-ssh-access-handlers.test.ts delete mode 100644 src/main/ipc/runtime-ssh-access-handlers.ts delete mode 100644 src/main/ipc/runtime-watcher-disposal-owners.test.ts delete mode 100644 src/main/ipc/runtime-watcher-disposal-owners.ts delete mode 100644 src/main/ipc/runtime-watcher-pool-shutdown.test.ts delete mode 100644 src/main/ipc/ssh-target-crud-handlers-ownership.test.ts delete mode 100644 src/main/ipc/ssh-test-connection-probe-registry.test.ts delete mode 100644 src/main/ipc/ssh-test-probe-presence.test.ts delete mode 100644 src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts delete mode 100644 src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts delete mode 100644 src/main/memory/verified-local-folder-workspaces.test.ts delete mode 100644 src/main/memory/verified-local-folder-workspaces.ts delete mode 100644 src/main/orca-profiles/profile-session-markdown-transfer.ts delete mode 100644 src/main/orca-profiles/profile-session-owner-transfer.test.ts delete mode 100644 src/main/orca-profiles/profile-session-owner-transfer.ts delete mode 100644 src/main/orcad-migration-export-holds.test.ts delete mode 100644 src/main/orcad/external-chromium-browser-process.test.ts delete mode 100644 src/main/orcad/orcad-browser-startup-cancellation.test.ts delete mode 100644 src/main/orcad/orcad-browser-startup.test.ts delete mode 100644 src/main/orcad/orcad-browser-startup.ts delete mode 100644 src/main/orcad/orcad-completed-stop-receipt.ts delete mode 100644 src/main/orcad/orcad-daemon-retirement.test.ts delete mode 100644 src/main/orcad/orcad-daemon-retirement.ts delete mode 100644 src/main/orcad/orcad-lifecycle-host.test.ts delete mode 100644 src/main/orcad/orcad-managed-stop-admission.ts delete mode 100644 src/main/orcad/orcad-managed-stop-cancellation.test.ts delete mode 100644 src/main/orcad/orcad-managed-stop-cancellation.ts delete mode 100644 src/main/orcad/orcad-managed-stop-command.ts delete mode 100644 src/main/orcad/orcad-managed-stop-completion.ts delete mode 100644 src/main/orcad/orcad-managed-stop-decision.ts delete mode 100644 src/main/orcad/orcad-managed-stop-request.ts delete mode 100644 src/main/orcad/orcad-managed-stop.test.ts delete mode 100644 src/main/orcad/orcad-migration-manifest-digest.ts delete mode 100644 src/main/orcad/orcad-runtime-lifetime.test.ts delete mode 100644 src/main/orcad/orcad-runtime-lifetime.ts delete mode 100644 src/main/orcad/orcad-stop-request-listener.test.ts delete mode 100644 src/main/orcad/orcad-stop-request-listener.ts delete mode 100644 src/main/orcad/orcad-terminal-census.test.ts delete mode 100644 src/main/orcad/orcad-terminal-census.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts delete mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts delete mode 100644 src/main/providers/ssh-pty-notification-recovery-activation.test.ts delete mode 100644 src/main/providers/ssh-pty-notification-routing-recovery.test.ts delete mode 100644 src/main/providers/ssh-pty-notification-routing-test-fixture.ts delete mode 100644 src/main/providers/ssh-pty-process-list.ts delete mode 100644 src/main/providers/ssh-pty-spawn-env.test.ts delete mode 100644 src/main/runtime/orca-runtime-headless-terminal-close.test.ts delete mode 100644 src/main/runtime/rpc/methods/orcad-terminal-census.ts delete mode 100644 src/main/runtime/rpc/node-websocket-lifecycle.ts delete mode 100644 src/main/runtime/rpc/websocket-transport-limits.ts delete mode 100644 src/main/runtime/runtime-environment-identity-verification.ts delete mode 100644 src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts delete mode 100644 src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts delete mode 100644 src/main/runtime/runtime-rpc-required-ws-port.test.ts delete mode 100644 src/main/ssh/orcad-activation-crash-recovery.test.ts delete mode 100644 src/main/ssh/orcad-activation-host-test-harness.ts delete mode 100644 src/main/ssh/orcad-activation-lock.ts delete mode 100644 src/main/ssh/orcad-activation-recovery.ts delete mode 100644 src/main/ssh/orcad-activation-transaction-schema.ts delete mode 100644 src/main/ssh/orcad-activation-transaction-store.ts delete mode 100644 src/main/ssh/orcad-activation-transaction-transitions.ts delete mode 100644 src/main/ssh/orcad-activation-transaction.test.ts delete mode 100644 src/main/ssh/orcad-activation-transaction.ts delete mode 100644 src/main/ssh/orcad-active-readiness.ts delete mode 100644 src/main/ssh/orcad-daemon-protocol-crossing.ts delete mode 100644 src/main/ssh/orcad-decommission-recovery.ts delete mode 100644 src/main/ssh/orcad-decommission-stop.ts delete mode 100644 src/main/ssh/orcad-decommission-transaction.test.ts delete mode 100644 src/main/ssh/orcad-decommission-transaction.ts delete mode 100644 src/main/ssh/orcad-gc-transaction-pins.ts delete mode 100644 src/main/ssh/orcad-incumbent-recovery.ts delete mode 100644 src/main/ssh/orcad-initial-activation-admission.ts delete mode 100644 src/main/ssh/orcad-installed-activation.ts delete mode 100644 src/main/ssh/orcad-managed-lifecycle-test-fixture.ts delete mode 100644 src/main/ssh/orcad-managed-remote-stop.ts delete mode 100644 src/main/ssh/orcad-managed-runtime-context.ts delete mode 100644 src/main/ssh/orcad-managed-tunnel-resume.ts delete mode 100644 src/main/ssh/orcad-managed-tunnel.test.ts delete mode 100644 src/main/ssh/orcad-managed-tunnel.ts delete mode 100644 src/main/ssh/orcad-managed-update-deferrals.ts delete mode 100644 src/main/ssh/orcad-migration-manifest-export.test.ts delete mode 100644 src/main/ssh/orcad-migration-manifest-export.ts delete mode 100644 src/main/ssh/orcad-migration-snapshot-coordinator.test.ts delete mode 100644 src/main/ssh/orcad-migration-snapshot-coordinator.ts delete mode 100644 src/main/ssh/orcad-recovery-slot.ts delete mode 100644 src/main/ssh/orcad-remote-build-hash.ts delete mode 100644 src/main/ssh/orcad-remote-context.ts delete mode 100644 src/main/ssh/orcad-remote-decommission.test.ts create mode 100644 src/main/ssh/orcad-remote-deploy-stop.ts delete mode 100644 src/main/ssh/orcad-remote-primitives.test.ts delete mode 100644 src/main/ssh/orcad-remote-record-file.ts delete mode 100644 src/main/ssh/orcad-remote-runtime-control.ts delete mode 100644 src/main/ssh/orcad-remote-stop.ts delete mode 100644 src/main/ssh/orcad-rollback-transition.ts delete mode 100644 src/main/ssh/orcad-runtime-decommission.test.ts delete mode 100644 src/main/ssh/orcad-runtime-decommission.ts delete mode 100644 src/main/ssh/orcad-runtime-deployment.test.ts delete mode 100644 src/main/ssh/orcad-runtime-deployment.ts delete mode 100644 src/main/ssh/orcad-runtime-lifecycle.ts delete mode 100644 src/main/ssh/orcad-runtime-maintenance.test.ts delete mode 100644 src/main/ssh/orcad-runtime-maintenance.ts delete mode 100644 src/main/ssh/orcad-runtime-status.ts delete mode 100644 src/main/ssh/orcad-ssh-provisioning.test.ts delete mode 100644 src/main/ssh/orcad-ssh-provisioning.ts delete mode 100644 src/main/ssh/orcad-state-snapshot-shell.test.ts delete mode 100644 src/main/ssh/orcad-terminal-census-client.test.ts delete mode 100644 src/main/ssh/orcad-terminal-census-client.ts delete mode 100644 src/main/ssh/orcad-tunneled-pairing.test.ts delete mode 100644 src/main/ssh/orcad-tunneled-pairing.ts delete mode 100644 src/main/ssh/runtime-ssh-access-verification.test.ts delete mode 100644 src/main/ssh/runtime-ssh-access-verification.ts delete mode 100644 src/main/ssh/runtime-ssh-access.test.ts delete mode 100644 src/main/ssh/runtime-ssh-access.ts delete mode 100644 src/main/ssh/ssh-channel-open.ts delete mode 100644 src/main/ssh/ssh-connection-channel-error.test.ts delete mode 100644 src/main/ssh/ssh-connection-channel-lifetime.test.ts delete mode 100644 src/main/ssh/ssh-connection-channel-lifetime.ts delete mode 100644 src/main/ssh/ssh-connection-close-drain.ts delete mode 100644 src/main/ssh/ssh-connection-destination-capture.test.ts delete mode 100644 src/main/ssh/ssh-connection-destination.test.ts delete mode 100644 src/main/ssh/ssh-connection-destination.ts delete mode 100644 src/main/ssh/ssh-connection-disconnect-drain.test.ts delete mode 100644 src/main/ssh/ssh-connection-file-transfers.ts delete mode 100644 src/main/ssh/ssh-connection-manager-closure.test.ts delete mode 100644 src/main/ssh/ssh-connection-manager-test-probes.ts delete mode 100644 src/main/ssh/ssh-connection-store-orcad-ownership.test.ts delete mode 100644 src/main/ssh/ssh-connection-store-test-fixture.ts delete mode 100644 src/main/ssh/ssh-connection-system-work-drain.test.ts delete mode 100644 src/main/ssh/ssh-connection-transport-closure.test.ts delete mode 100644 src/main/ssh/ssh-connection-transport-closure.ts delete mode 100644 src/main/ssh/ssh-connection-work-drain.test.ts delete mode 100644 src/main/ssh/ssh-connection-work-ledger.test.ts delete mode 100644 src/main/ssh/ssh-connection-work-ledger.ts delete mode 100644 src/main/ssh/ssh-forward-channel-lifetime.test.ts delete mode 100644 src/main/ssh/ssh-forward-channel-lifetime.ts delete mode 100644 src/main/ssh/ssh-forward-socket-admission.test.ts delete mode 100644 src/main/ssh/ssh-provider-continuations.ts delete mode 100644 src/main/ssh/ssh-pty-consumer-resume-session.test.ts delete mode 100644 src/main/ssh/ssh-relay-exec-input.test.ts delete mode 100644 src/main/ssh/ssh-target-orcad-claims.test.ts delete mode 100644 src/main/ssh/ssh-target-orcad-claims.ts delete mode 100644 src/main/ssh/ssh-target-orcad-dependents-fixture.ts delete mode 100644 src/main/ssh/ssh-target-orcad-dependents.test.ts delete mode 100644 src/main/ssh/ssh-target-orcad-dependents.ts delete mode 100644 src/main/ssh/ssh-target-orcad-preflight.test.ts delete mode 100644 src/main/ssh/ssh-target-orcad-preflight.ts delete mode 100644 src/main/ssh/ssh-transport-close-ledger.test.ts delete mode 100644 src/main/ssh/ssh-transport-close-ledger.ts delete mode 100644 src/main/ssh/ssh-transport-close-observation.test.ts delete mode 100644 src/main/ssh/ssh-transport-close-observation.ts delete mode 100644 src/main/ssh/ssh-upload-session-lifetime.test.ts delete mode 100644 src/main/ssh/ssh-upload-session-lifetime.ts delete mode 100644 src/relay/agent-exec-disposal.test.ts delete mode 100644 src/relay/dispatcher-producer-settlement.test.ts delete mode 100644 src/relay/dispatcher-work-drain.test.ts delete mode 100644 src/relay/fs-file-stream-shutdown.test.ts delete mode 100644 src/relay/fs-stream-registry-shutdown.test.ts delete mode 100644 src/relay/fs-stream-terminal-frame-slots.ts delete mode 100644 src/relay/git-response-stream-shutdown.test.ts delete mode 100644 src/relay/relay-agent-process-lifetime.ts delete mode 100644 src/relay/relay-grace-lifecycle.test.ts delete mode 100644 src/relay/relay-owner-reset-dispatch.test.ts delete mode 100644 src/relay/relay-owner-reset-preparation-journal.test.ts delete mode 100644 src/relay/relay-owner-reset-preparation-journal.ts delete mode 100644 src/relay/relay-owner-reset-registration.test.ts delete mode 100644 src/relay/relay-owner-reset-registration.ts delete mode 100644 src/relay/relay-owner-reset.test.ts delete mode 100644 src/relay/relay-owner-reset.ts delete mode 100644 src/relay/relay-producer-publication-drain.test.ts delete mode 100644 src/relay/relay-producer-publication-drain.ts delete mode 100644 src/relay/relay-reset-preparation-reader.integration.test.ts delete mode 100644 src/relay/relay-reset-preparation-reader.test.ts delete mode 100644 src/relay/relay-reset-preparation-reader.ts delete mode 100644 src/relay/relay-runtime-owned-process-shutdown.test.ts delete mode 100644 src/relay/relay-watcher-shutdown.test.ts delete mode 100644 src/relay/relay-watcher-shutdown.ts delete mode 100644 src/relay/relay-work-admission.test.ts delete mode 100644 src/relay/relay-work-admission.ts delete mode 100644 src/relay/ssh-pty-consumer-resume-client.test.ts delete mode 100644 src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts delete mode 100644 src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts delete mode 100644 src/shared/managed-orcad-ssh-owner.test.ts delete mode 100644 src/shared/managed-orcad-ssh-owner.ts delete mode 100644 src/shared/orcad-decommission.ts delete mode 100644 src/shared/orcad-managed-runtime.ts delete mode 100644 src/shared/orcad-migration-catalog-state.ts delete mode 100644 src/shared/orcad-migration-client-state-parsing.ts delete mode 100644 src/shared/orcad-migration-client-state-value-validation.ts delete mode 100644 src/shared/orcad-migration-client-state.ts delete mode 100644 src/shared/orcad-migration-dormant-automation-fields.ts delete mode 100644 src/shared/orcad-migration-dormant-automation-validation.ts delete mode 100644 src/shared/orcad-migration-dormant-session-validation.test.ts delete mode 100644 src/shared/orcad-migration-dormant-session-validation.ts delete mode 100644 src/shared/orcad-migration-dormant-state-entry-validation.ts delete mode 100644 src/shared/orcad-migration-dormant-state-validation.ts delete mode 100644 src/shared/orcad-migration-dormant-value-validation.ts delete mode 100644 src/shared/orcad-migration-manifest-contract.test.ts delete mode 100644 src/shared/orcad-migration-manifest-fields.ts delete mode 100644 src/shared/orcad-migration-manifest-validation.ts delete mode 100644 src/shared/orcad-migration-manifest.test.ts delete mode 100644 src/shared/orcad-migration-manifest.ts delete mode 100644 src/shared/orcad-migration-preflight.ts delete mode 100644 src/shared/orcad-migration-scrollback.test.ts delete mode 100644 src/shared/orcad-migration-scrollback.ts delete mode 100644 src/shared/orcad-migration-staged-catalog-validation.test.ts delete mode 100644 src/shared/orcad-migration-staged-catalog-validation.ts delete mode 100644 src/shared/orcad-ssh-provisioning.ts delete mode 100644 src/shared/orcad-stop-request.ts delete mode 100644 src/shared/orcad-terminal-census.ts delete mode 100644 src/shared/pty-consumer-session-resume-only.test.ts delete mode 100644 src/shared/pty-ownership-transfer-release-gate.test.ts delete mode 100644 src/shared/pty-ownership-transfer-release-gate.ts delete mode 100644 src/shared/relay-owner-reset-contract.test.ts delete mode 100644 src/shared/relay-owner-reset-contract.ts delete mode 100644 src/shared/relay-reset-preparation-contract.test.ts delete mode 100644 src/shared/relay-reset-preparation-contract.ts delete mode 100644 src/shared/relay-work-drain-contract.ts delete mode 100644 src/shared/runtime-environment-authority-binding.ts delete mode 100644 src/shared/runtime-environment-managed-orcad-store.test.ts delete mode 100644 src/shared/runtime-environment-managed-orcad-store.ts delete mode 100644 src/shared/runtime-environment-reconciliation-record.ts delete mode 100644 src/shared/runtime-environment-shipped-store-fixture.ts delete mode 100644 src/shared/runtime-environment-sidecar.test.ts delete mode 100644 src/shared/runtime-environment-sidecar.ts delete mode 100644 src/shared/runtime-environment-ssh-access-store.test.ts delete mode 100644 src/shared/runtime-environment-ssh-access-store.ts delete mode 100644 src/shared/runtime-environment-store-file.ts delete mode 100644 src/shared/runtime-environments-ssh-access.test.ts delete mode 100644 src/shared/runtime-rpc-call-queue-retirement.test.ts delete mode 100644 src/shared/runtime-ssh-access.ts delete mode 100644 src/shared/transport-publication-drain.test.ts delete mode 100644 src/shared/transport-publication-drain.ts diff --git a/config/scripts/orcad-daemon-protocol-crossing.test.mjs b/config/scripts/orcad-daemon-protocol-crossing.test.mjs deleted file mode 100644 index 131c1d54c57..00000000000 --- a/config/scripts/orcad-daemon-protocol-crossing.test.mjs +++ /dev/null @@ -1,100 +0,0 @@ -// D7: orcad's update and rollback planning must agree with the CI protocol-crossing facts. -import { readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { - DAEMON_PROTOCOL_SOURCE_PATH, - canAttach, - parseDaemonProtocolFacts -} from './daemon-protocol-facts.mjs' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from '../../src/main/ssh/orcad-daemon-protocol-crossing' -import { assessOrcadRollback, planOrcadUpdate } from '../../src/main/ssh/orcad-update-plan' - -const projectDir = resolve(import.meta.dirname, '../..') -const current = parseDaemonProtocolFacts( - readFileSync(join(projectDir, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8') -) -// The release before the newest protocol bump: it speaks one version lower and cannot list ours. -const older = { - protocolVersion: current.protocolVersion - 1, - previousProtocolVersions: current.previousProtocolVersions.filter( - (version) => version < current.protocolVersion - 1 - ) -} -const record = { - schemaVersion: 1, - active: '0.3.0+new', - previous: '0.2.0+old', - activatedAt: '2026-01-01T00:00:00.000Z', - snapshot: { - dirName: 'pre-0.3.0+new-1', - takenBeforeVersion: '0.3.0+new', - readableByVersion: '0.2.0+old', - takenAt: '2026-01-01T00:00:00.000Z' - } -} -const live = (daemonProtocolVersion) => ({ - liveSessions: 2, - startedSinceActivation: 0, - daemonProtocolVersion -}) - -function rollback(target, daemonProtocolVersion) { - return assessOrcadRollback({ - record, - snapshotPresent: true, - census: live(daemonProtocolVersion), - targetDaemonProtocol: target, - stateWritesSinceActivation: false - }) -} - -describe('orcad daemon protocol crossing', () => { - it('deploys exactly the protocol the working tree declares', () => { - expect({ - protocolVersion: CURRENT_ORCAD_DAEMON_PROTOCOL.protocolVersion, - previousProtocolVersions: [...CURRENT_ORCAD_DAEMON_PROTOCOL.previousProtocolVersions] - }).toEqual(current) - }) - - it('keeps terminals on rollback only when the old build lists the new protocol', () => { - expect(canAttach(older, current)).toBe(false) - expect(rollback(older, current.protocolVersion)).toMatchObject({ - safety: 'unsafe', - code: 'orcad_rollback_strands_live_terminals' - }) - // A daemon preserved from before the activation still speaks the old build's protocol. - expect(canAttach(older, older)).toBe(true) - expect(rollback(older, older.protocolVersion)).toMatchObject({ safety: 'clean' }) - const listing = { ...older, previousProtocolVersions: [...older.previousProtocolVersions] } - listing.previousProtocolVersions.push(current.protocolVersion + 1) - expect(canAttach(listing, { ...current, protocolVersion: current.protocolVersion + 1 })).toBe( - true - ) - expect(rollback(listing, current.protocolVersion + 1)).toMatchObject({ safety: 'clean' }) - }) - - it('updates over live terminals only when the candidate can attach their daemon', () => { - const plan = (daemonProtocolVersion) => - planOrcadUpdate({ - record, - candidateVersion: '0.4.0+next', - census: live(daemonProtocolVersion), - candidateDaemonProtocol: current, - force: true - }) - expect(canAttach(current, older)).toBe(true) - expect(plan(older.protocolVersion)).toMatchObject({ - action: 'proceed', - preservesLiveDaemon: true - }) - const dropped = current.protocolVersion + 1 - expect(canAttach(current, { protocolVersion: dropped, previousProtocolVersions: [] })).toBe( - false - ) - expect(plan(dropped)).toMatchObject({ - action: 'defer', - code: 'orcad_update_strands_live_terminals' - }) - }) -}) diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index f3830db82ed..adc18586c21 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -150,47 +150,6 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to exits with code 1 if teardown stalls. The bundled runtime also stops gracefully if its launcher's IPC channel closes. On POSIX, both the launcher and runtime ignore `SIGHUP`, so terminal hangups do not stop a headless host. Use `SIGTERM` or `SIGINT` to stop it. -- **Stop requests.** A file stops orcad the same way `SIGTERM` does, without a PID that may - since have been reused by another process: - - `.orcad-stop-request` beside `orcad.js` in the running slot. orcad deletes it and stops. - - An instance-bound request in the data root, named - `.orcad-managed-stop-request.`. orcad stops only when it - names this orcad's version, runtime ID, PID, start time and lock nonce, and while the - instance lock still holds that record. The file is kept as evidence. - - `orcad --complete-managed-stop ''` writes that request, waits for the - instance to exit, and prints one JSON line whose `verdict` is `live`, `unverifiable` or - `exited`. `exited` needs proof: no process with that PID, or a PID whose start time shows - it now belongs to another process. On `exited` it writes - `/orcad-stop-receipts/.json`. It exits 0 whenever it printed a - verdict, 64 for a malformed invocation, and 1 for a failure before any verdict, which is - never evidence of exit. - - A request with `retireIdleDaemon: true` asks orcad to retire the terminal daemon too. This - is best effort and never blocks or fails the stop: - - The daemon is retired only when it proves it owns no live session across every - generation. - - A busy daemon (`live`) or one whose state cannot be proven (`unverifiable`) stays up with - its terminals, and orcad reopens new-terminal admission before exiting. - - The completed-stop receipt records `retirement` as `retired`, `live` or `unverifiable`. - If orcad exits without recording an outcome, the receipt says `unverifiable`. - - `orcad --cancel-managed-stop ''` withdraws a request orcad has not acted on. - orcad and the canceller each try to create `.decision.json` exclusively, - so exactly one wins. `canceled` means orcad keeps running and the request file is removed; - `dispatched` means orcad already began stopping, and only the completion can say how it - ended. - - A build advertises all of the above with `health.stopRequests: 1` in its readiness line. - Clients stop such a build through the slot request file and older builds with `SIGTERM`, - after corroborating the PID with readiness either way. A launch clears a slot request - that the previous process never consumed. -- **Decommissioning a managed slot.** An Orca client decommissions through the same activation - journal and fence as deploy and rollback. It refuses while the terminal census reports live - or uncounted terminals, stops the instance with a managed request that also asks to retire - the daemon, and records that no version is active only after `exited` is proven. A stop - that did not finish is cancelled; if orcad already acted on it, or the host cannot answer, - the fence stays for recovery. -- **Instance lock.** `/orcad.lock` names the running orcad. A record that is - unreadable, malformed or over 64 KiB is never reclaimed: orcad exits 78 until an operator - removes it. A shutdown whose teardown failed keeps the lock until the process exits, so a - second orcad cannot start beside a writer that may still be running. - **Exit codes.** | Code | Meaning | Supervisor should | diff --git a/src/main/daemon/daemon-health-identity.ts b/src/main/daemon/daemon-health-identity.ts deleted file mode 100644 index 44a1c02978e..00000000000 --- a/src/main/daemon/daemon-health-identity.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** What a `ptySpawnHealth` reply proves about this daemon; every field is optional on the wire. */ -export function readDaemonHealthIdentity(): { - coverage: 'pty-spawn' | 'handshake' - runtimeKind: 'node' - runtimeVersion: string -} { - return { - // Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there. - coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn', - runtimeKind: 'node', - runtimeVersion: process.version - } -} diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 5b00b7c91e5..c499fadd551 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -11,7 +11,6 @@ import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' import type { SocketProbeOutcome } from './daemon-endpoint-probe' import { checkDaemonHealth, - checkDaemonHealthWithCoverage, E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV, healthCheckDaemon } from './daemon-health' @@ -106,63 +105,12 @@ describe('daemon health', () => { try { await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy') await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true) - await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({ - verdict: 'healthy', - coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn', - runtimeKind: 'node', - runtimeVersion: process.version - }) - expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(3) + expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(2) } finally { await server.shutdown() } }) - it('treats missing coverage from a legacy Windows daemon as handshake-only', async () => { - writeFileSync(tokenPath, 'legacy-token') - const server = createServer((socket) => { - let pending = '' - socket.on('data', (chunk) => { - pending += chunk.toString() - for (;;) { - const newline = pending.indexOf('\n') - if (newline === -1) { - return - } - const message: unknown = JSON.parse(pending.slice(0, newline)) - const type = - typeof message === 'object' && message !== null && 'type' in message - ? message.type - : undefined - pending = pending.slice(newline + 1) - if (type === 'hello') { - socket.write(`${JSON.stringify({ type: 'hello', ok: true })}\n`) - } else if (type === 'ptySpawnHealth') { - socket.write( - `${JSON.stringify({ id: 'health-1', ok: true, payload: { healthy: true } })}\n` - ) - } - } - }) - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(socketPath, resolve) - }) - - const platform = Object.getOwnPropertyDescriptor(process, 'platform')! - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toEqual({ - verdict: 'healthy', - coverage: 'handshake' - }) - } finally { - Object.defineProperty(process, 'platform', platform) - await closeServer(server) - } - }) - it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => { const server = new DaemonServer({ socketPath, diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index cc035c26a08..d3a2c8a91a9 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -21,57 +21,15 @@ export const E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV = 'ORCA_E2E_FORCE_DAEMON_HE // also covers a live-but-wedged daemon that simply missed the RPC budget. export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy' -export type DaemonHealthCheck = { - verdict: DaemonHealth - coverage: 'pty-spawn' | 'handshake' - /** Optional runtime proof from newer daemons; absent on mixed-version peers. */ - runtimeKind?: 'node' - runtimeVersion?: string -} - -function readRuntimeIdentity( - payload: unknown -): Pick { - if (typeof payload !== 'object' || payload === null) { - return {} - } - const runtimeKind = 'runtimeKind' in payload ? payload.runtimeKind : undefined - const runtimeVersion = 'runtimeVersion' in payload ? payload.runtimeVersion : undefined - // Why drop other kinds: only a Node daemon reports here; anything else is an unknown peer. - return { - ...(runtimeKind === 'node' ? { runtimeKind } : {}), - ...(typeof runtimeVersion === 'string' && runtimeVersion.length > 0 ? { runtimeVersion } : {}) - } -} - -function readPtySpawnHealthCoverage( - payload: unknown, - fallback: DaemonHealthCheck['coverage'] -): DaemonHealthCheck['coverage'] { - if (typeof payload !== 'object' || payload === null) { - return fallback - } - const coverage = 'coverage' in payload ? payload.coverage : undefined - return coverage === 'pty-spawn' || coverage === 'handshake' ? coverage : fallback -} - -export function checkDaemonHealthWithCoverage( - socketPath: string, - tokenPath: string -): Promise { +export function checkDaemonHealth(socketPath: string, tokenPath: string): Promise { return new Promise((resolve) => { - // Older Windows daemons answered this RPC without spawning; an absent optional coverage - // field must preserve that weaker meaning during adoption. - const fallbackCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' - const resolveVerdict = (verdict: DaemonHealth): void => - resolve({ verdict, coverage: fallbackCoverage }) if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') { - resolveVerdict('unreachable') + resolve('unreachable') return } if (process.platform !== 'win32' && !existsSync(socketPath)) { - resolveVerdict('unreachable') + resolve('unreachable') return } @@ -79,13 +37,13 @@ export function checkDaemonHealthWithCoverage( try { token = readFileSync(tokenPath, 'utf8').trim() } catch { - resolveVerdict('unreachable') + resolve('unreachable') return } let settled = false let sock: Socket | null = null - const settle = (result: DaemonHealthCheck): void => { + const settle = (result: DaemonHealth): void => { if (settled) { return } @@ -100,7 +58,7 @@ export function checkDaemonHealthWithCoverage( sock?.off('connect', onConnect) sock?.off('data', onData) } - const onError = (): void => settle({ verdict: 'unreachable', coverage: fallbackCoverage }) + const onError = (): void => settle('unreachable') const onConnect = (): void => { const hello: HelloMessage = { type: 'hello', @@ -131,13 +89,13 @@ export function checkDaemonHealthWithCoverage( try { message = JSON.parse(line) as Record } catch { - settle({ verdict: 'rejected', coverage: fallbackCoverage }) + settle('rejected') return } if (message.type === 'hello') { if (!(message as HelloResponse).ok) { - settle({ verdict: 'rejected', coverage: fallbackCoverage }) + settle('rejected') return } // Why: a protocol-live daemon with a stale cwd or node-pty helper @@ -148,20 +106,12 @@ export function checkDaemonHealthWithCoverage( } if (message.id === 'health-1') { - const identity = readRuntimeIdentity(message.payload) - settle({ - verdict: message.ok === true ? 'healthy' : 'pty-spawn-unhealthy', - coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage), - ...identity - }) + settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy') return } } } - const timer = setTimeout( - () => settle({ verdict: 'unreachable', coverage: fallbackCoverage }), - HEALTH_CHECK_TIMEOUT_MS - ) + const timer = setTimeout(() => settle('unreachable'), HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) @@ -172,13 +122,6 @@ export function checkDaemonHealthWithCoverage( }) } -export async function checkDaemonHealth( - socketPath: string, - tokenPath: string -): Promise { - return (await checkDaemonHealthWithCoverage(socketPath, tokenPath)).verdict -} - export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise { return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy' } diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts index 43a82df72bb..1500718e98f 100644 --- a/src/main/daemon/daemon-idle-shutdown.test.ts +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -442,35 +442,6 @@ describe('current daemon lifecycle retirement', () => { adopted.dispose() }) - it('atomically retires an idle daemon and permanently fences adapter spawns', async () => { - await startServer() - const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) - - await expect(adapter.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - await expect( - adapter.spawn({ sessionId: 'late-after-decommission', cols: 80, rows: 24 }) - ).rejects.toThrow('Terminal daemon is decommissioning') - await waitFor(() => onIdleShutdown.mock.calls.length === 1) - adapter.dispose() - }) - - it('reopens adapter admission when the daemon refuses retirement for a live session', async () => { - await startServer() - const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) - await adapter.spawn({ sessionId: 'already-live', cols: 80, rows: 24 }) - - await expect(adapter.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - await expect( - adapter.spawn({ sessionId: 'allowed-after-refusal', cols: 80, rows: 24 }) - ).resolves.toMatchObject({ id: 'allowed-after-refusal' }) - expect(onIdleShutdown).not.toHaveBeenCalled() - adapter.dispose() - }) - it('does not let repeated authenticated control probes extend the startup deadline', async () => { await startServer() const healthControl = connect(socketPath) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 6f0bd4eefc5..918547d8b79 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -8,9 +8,6 @@ export { getDaemonEndpointFacts, getDaemonProvider, listLiveDaemonPtyIds, - listLiveDaemonSessions, - requestIdleDaemonRetirement, - releaseDaemonRetirementFence, readDaemonPidRecord, replaceDaemonProvider, shutdownDaemon, diff --git a/src/main/daemon/daemon-provider-census.test.ts b/src/main/daemon/daemon-provider-census.test.ts deleted file mode 100644 index 30aade980fd..00000000000 --- a/src/main/daemon/daemon-provider-census.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' - -vi.mock('../ipc/pty', () => ({ setLocalPtyProvider: vi.fn() })) - -import { DaemonPtyRouter } from './daemon-pty-router' -import { createAdapter } from './daemon-pty-router-test-fixture' -import { - disconnectDaemon, - listLiveDaemonSessions, - listLiveDaemonSessionsWithProtocol, - replaceDaemonProvider, - requestIdleDaemonRetirement -} from './daemon-provider-state' -import { PROTOCOL_VERSION } from './types' - -afterEach(async () => { - await disconnectDaemon() -}) - -it('reads a census without an installed daemon as unverifiable, never as empty', async () => { - await expect(listLiveDaemonSessions()).resolves.toBeNull() - await expect(requestIdleDaemonRetirement()).resolves.toEqual({ state: 'unverifiable' }) -}) - -it('reads a census with an unanswered generation as unverifiable', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.listSessions).mockRejectedValue(new Error('daemon unreachable')) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessions()).resolves.toBeNull() -}) - -it('labels each live session with the protocol of the generation that owns it', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION - 1) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessionsWithProtocol()).resolves.toEqual([ - { sessionId: 'live-1', isAlive: true, protocolVersion: PROTOCOL_VERSION }, - { sessionId: 'live-2', isAlive: true, protocolVersion: PROTOCOL_VERSION - 1 } - ]) -}) - -it('lists every generation when each one answers', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessions()).resolves.toEqual([ - { sessionId: 'live-1', isAlive: true }, - { sessionId: 'live-2', isAlive: true } - ]) -}) diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index f54d886206d..412dda3af83 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -11,16 +11,12 @@ import { getMacDaemonTccAttributionHealth, type MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' -import { PROTOCOL_VERSION, type DaemonSessionInfo, type SessionInfo } from './types' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' +import { PROTOCOL_VERSION } from './types' let spawner: DaemonSpawner | null = null let adapter: DaemonProvider | null = null -export function installDaemonProvider( - newSpawner: DaemonSpawner | null, - newAdapter: DaemonProvider -): void { +export function installDaemonProvider(newSpawner: DaemonSpawner, newAdapter: DaemonProvider): void { spawner = newSpawner replaceDaemonProvider(newAdapter) } @@ -39,12 +35,7 @@ export function getDaemonSpawner(): DaemonSpawner | null { * from that state would be advertising recovery for terminals that cannot be recovered. */ export function daemonOwnsFreshPersistentPtys(): boolean { - if (!adapter || adapter instanceof DegradedDaemonPtyProvider) { - return false - } - return adapter instanceof DaemonPtyRouter - ? !adapter.getAllAdapters().some((entry) => entry.recoveryOnly) - : !adapter.recoveryOnly + return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider) } /** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */ @@ -104,27 +95,6 @@ export async function getCurrentDaemonMacTccAttributionHealth(): Promise { - await adapter?.disconnectOnly() - adapter = null -} - -/** Kill the daemon and all its sessions. Use for full cleanup only. */ -export async function shutdownDaemon(): Promise { - adapter?.dispose() - adapter = null - await spawner?.shutdown() - spawner = null -} - /** Returns null unless every daemon generation supplied an authoritative inventory. */ export async function listLiveDaemonPtyIds(): Promise { if (!adapter) { @@ -145,54 +115,23 @@ export async function listLiveDaemonPtyIds(): Promise { ) } -/** Returns null unless every daemon generation supplied an authoritative session inventory. */ -export async function listLiveDaemonSessions(): Promise { - const sessions = await listLiveDaemonSessionsWithProtocol() - return sessions?.map(({ protocolVersion: _protocolVersion, ...session }) => session) ?? null +// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference. +export function replaceDaemonProvider(newAdapter: DaemonProvider): void { + adapter = newAdapter + setLocalPtyProvider(newAdapter) } -/** Like listLiveDaemonSessions, with the protocol of the daemon generation owning each session. */ -export async function listLiveDaemonSessionsWithProtocol(): Promise { - if (!adapter) { - return null - } - const adapters = - adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider - ? adapter.getAllAdapters() - : [adapter] - const inventories = await Promise.allSettled( - adapters.map(async (daemonAdapter) => - (await daemonAdapter.listSessions()).map((session) => ({ - ...session, - protocolVersion: daemonAdapter.protocolVersion - })) - ) - ) - if (inventories.some((inventory) => inventory.status === 'rejected')) { - return null - } - return inventories.flatMap((inventory) => - inventory.status === 'fulfilled' ? inventory.value : [] - ) +// Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach. +// Leave history sessions marked "unclean" so a daemon crash while Orca is closed stays recoverable. +export async function disconnectDaemon(): Promise { + await adapter?.disconnectOnly() + adapter = null } -/** Atomically fence new daemon terminals and retire only an idle, single-generation daemon. */ -export async function requestIdleDaemonRetirement(): Promise { - if (!adapter) { - return { state: 'unverifiable' } - } - if (adapter instanceof DegradedDaemonPtyProvider) { - return { state: 'unverifiable' } - } - if (adapter instanceof DaemonPtyRouter) { - return adapter.requestIdleRetirement() - } - return adapter.requestIdleRetirement() -} - -/** Reopens terminal admission when an idle-retirement attempt did not retire the daemon. */ -export function releaseDaemonRetirementFence(): void { - if (adapter && !(adapter instanceof DegradedDaemonPtyProvider)) { - adapter.releaseIdleRetirementFence() - } +/** Kill the daemon and all its sessions. Use for full cleanup only. */ +export async function shutdownDaemon(): Promise { + adapter?.dispose() + adapter = null + await spawner?.shutdown() + spawner = null } diff --git a/src/main/daemon/daemon-pty-event-subscriptions.ts b/src/main/daemon/daemon-pty-event-subscriptions.ts index f007a734b40..6d95e2de1c7 100644 --- a/src/main/daemon/daemon-pty-event-subscriptions.ts +++ b/src/main/daemon/daemon-pty-event-subscriptions.ts @@ -3,12 +3,7 @@ import { removeDaemonListener } from './daemon-listener-registry' import { emitPtyListeners } from './daemon-pty-listener-emission' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import { DaemonPtySessionInventory } from './daemon-pty-session-inventory' -import { - CLEAN_DISCONNECT_PROTOCOL_VERSION, - type ListSessionsResult, - type ShutdownIfIdleResult -} from './types' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' import type { PtyBackgroundStreamEvent } from '../providers/types' export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory { @@ -90,75 +85,6 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent this.recordAuthenticatedIdentity() } - async requestIdleRetirement(): Promise { - if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { - return { state: 'unsupported' } - } - if (this.idleRetirementState === 'retiring') { - return { state: 'retiring' } - } - if (this.idleRetirementPromise) { - return this.idleRetirementPromise - } - if ( - this.disconnectOnlyPromise || - (this.respawnAdoptionClosed && this.idleRetirementState === 'open') - ) { - return { state: 'unverifiable' } - } - this.idleRetirementAdmissionClosed = true - this.respawnAdoptionClosed = true - this.idleRetirementState = 'checking' - const request = this.finishIdleRetirementRequest().finally(() => { - if (this.idleRetirementPromise === request) { - this.idleRetirementPromise = null - } - }) - this.idleRetirementPromise = request - return request - } - - private async finishIdleRetirementRequest(): Promise { - try { - await this.client.ensureConnected() - const result = await this.client.request('shutdownIfIdle', undefined) - if (result.retiring) { - this.idleRetirementState = 'retiring' - return { state: 'retiring' } - } - let liveSessions: number | null = null - try { - const inventory = await this.client.request('listSessions', undefined) - liveSessions = inventory.sessions.filter((session) => session.isAlive).length - } catch { - liveSessions = null - } - this.reopenAfterRefusedIdleRetirement() - return { - state: 'busy', - liveSessions, - ...(!this.recoveryOnly ? { admissionReopened: true as const } : {}) - } - } catch { - // The daemon may have accepted before contact was lost; keep admission and respawn fenced. - this.idleRetirementState = 'unverifiable' - return { state: 'unverifiable' } - } - } - - /** Reopens admission an idle-retirement attempt fenced without retiring the daemon. */ - releaseIdleRetirementFence(): void { - if (this.idleRetirementState !== 'retiring' && !this.idleRetirementPromise) { - this.reopenAfterRefusedIdleRetirement() - } - } - - private reopenAfterRefusedIdleRetirement(): void { - this.idleRetirementState = 'open' - this.idleRetirementAdmissionClosed = false - this.respawnAdoptionClosed = false - } - // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. async disconnectOnly(): Promise { diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index cc8f8da849a..335c641da62 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -127,7 +127,7 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. const { worktreeId } = parsePtySessionId(session.sessionId) - if (!this.recoveryOnly && (worktreeId === null || !validWorktreeIds.has(worktreeId))) { + if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { try { await this.client.request('kill', { sessionId: session.sessionId }) } catch { diff --git a/src/main/daemon/daemon-pty-router-test-fixture.ts b/src/main/daemon/daemon-pty-router-test-fixture.ts deleted file mode 100644 index 23d2c5f48b4..00000000000 --- a/src/main/daemon/daemon-pty-router-test-fixture.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { vi } from 'vitest' -import { settledWriteStub } from '../providers/settled-pty-write-stub' -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { - AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION -} from './types' -import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' - -type AdapterMock = DaemonPtyAdapter & { - emitData: (id: string, data: string, sequenceChars?: number) => void - emitBackground: (event: PtyBackgroundStreamEvent) => void - emitExit: (id: string, code: number, incarnationId?: string) => void - emitIdentityChange: () => void - triggerWriteUnavailable: (id: string) => void -} - -export function createAdapter( - label: string, - sessions: string[] = [], - reconcileResult?: { alive: string[]; killed: string[] }, - protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION -): AdapterMock { - const writes: { id: string; data: string }[] = [] - const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = - [] - const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] - const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] - const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = - [] - const identityChangeListeners: (() => void)[] = [] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the router calls only the adapter members this mock defines. - return { - protocolVersion, - supportsGitCredentialGuardHost: () => - protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, - supportsAgentSessionClaims: () => - protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - supportsAgentSessionCreateOperations: () => - protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - providesAgentSessionOwnerListings: () => - protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - canProvideAuthoritativeBufferSnapshot: () => - protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { - const id = opts.sessionId ?? `${label}-new` - sessions.push(id) - return { id } - }), - listProcesses: vi.fn(async () => - sessions.map((id) => ({ - id, - cwd: '', - title: label - })) - ), - listSessions: vi.fn(async () => sessions.map((sessionId) => ({ sessionId, isAlive: true }))), - requestIdleRetirement: vi.fn(async () => ({ state: 'retiring' as const })), - releaseIdleRetirementFence: vi.fn(), - hasPty: vi.fn((id: string) => sessions.includes(id)), - probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), - write: vi.fn((id: string, data: string) => { - writes.push({ id, data }) - }), - writeWithSettlement: vi.fn(settledWriteStub()), - resize: vi.fn(), - setPtyBackgrounded: vi.fn(), - getBufferSnapshot: vi.fn(async () => null), - shutdown: vi.fn(async (id: string) => { - const idx = sessions.indexOf(id) - if (idx !== -1) { - sessions.splice(idx, 1) - } - }), - attach: vi.fn(async () => {}), - sendSignal: vi.fn(async () => {}), - getCwd: vi.fn(async () => ''), - getInitialCwd: vi.fn(async () => ''), - clearBuffer: vi.fn(async () => {}), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(async () => false), - getForegroundProcess: vi.fn(async () => null), - inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), - confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), - serialize: vi.fn(async () => '{}'), - revive: vi.fn(async () => {}), - getDefaultShell: vi.fn(async () => '/bin/zsh'), - getProfiles: vi.fn(async () => []), - onData: vi.fn( - (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { - dataListeners.push(callback) - return () => { - const idx = dataListeners.indexOf(callback) - if (idx !== -1) { - dataListeners.splice(idx, 1) - } - } - } - ), - onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { - backgroundListeners.push(callback) - return () => { - const idx = backgroundListeners.indexOf(callback) - if (idx !== -1) { - backgroundListeners.splice(idx, 1) - } - } - }), - onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { - writeUnavailableListeners.push(callback) - return () => { - const idx = writeUnavailableListeners.indexOf(callback) - if (idx !== -1) { - writeUnavailableListeners.splice(idx, 1) - } - } - }), - onExit: vi.fn( - (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { - exitListeners.push(callback) - return () => { - const idx = exitListeners.indexOf(callback) - if (idx !== -1) { - exitListeners.splice(idx, 1) - } - } - } - ), - onDaemonIdentityChanged: vi.fn((callback: () => void) => { - identityChangeListeners.push(callback) - return () => { - const idx = identityChangeListeners.indexOf(callback) - if (idx !== -1) { - identityChangeListeners.splice(idx, 1) - } - } - }), - ackColdRestore: vi.fn(), - clearTombstone: vi.fn(), - reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), - dispose: vi.fn(), - disconnectOnly: vi.fn(async () => {}), - emitData: (id: string, data: string, sequenceChars?: number) => { - for (const listener of dataListeners) { - listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) - } - }, - emitBackground: (event: PtyBackgroundStreamEvent) => { - for (const listener of backgroundListeners) { - listener(event) - } - }, - emitExit: (id: string, code: number, incarnationId?: string) => { - for (const listener of exitListeners) { - listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) - } - }, - emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), - triggerWriteUnavailable: (id: string) => { - for (const listener of writeUnavailableListeners) { - listener({ id }) - } - }, - _writes: writes - } as unknown as AdapterMock -} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 7e35d416b12..61db990b21c 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,20 +1,29 @@ -import { createAdapter } from './daemon-pty-router-test-fixture' import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' -import { stubWriteSettlement } from '../providers/settled-pty-write-stub' import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtySpawnResult } from '../providers/types' +import { settledWriteStub, stubWriteSettlement } from '../providers/settled-pty-write-stub' +import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +type AdapterMock = DaemonPtyAdapter & { + emitData: (id: string, data: string, sequenceChars?: number) => void + emitBackground: (event: PtyBackgroundStreamEvent) => void + emitExit: (id: string, code: number, incarnationId?: string) => void + emitIdentityChange: () => void + triggerWriteUnavailable: (id: string) => void +} + const LARGE_RECONCILE_SESSION_COUNT = 150_000 function buildSessionIds(prefix: string, count: number): string[] { @@ -25,6 +34,152 @@ function buildSessionIds(prefix: string, count: number): string[] { return ids } +function createAdapter( + label: string, + sessions: string[] = [], + reconcileResult?: { alive: string[]; killed: string[] }, + protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION +): AdapterMock { + const writes: { id: string; data: string }[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] + const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] + const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] + const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = + [] + const identityChangeListeners: (() => void)[] = [] + return { + protocolVersion, + supportsGitCredentialGuardHost: () => + protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + supportsAgentSessionClaims: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + supportsAgentSessionCreateOperations: () => + protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + providesAgentSessionOwnerListings: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + canProvideAuthoritativeBufferSnapshot: () => + protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, + spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { + const id = opts.sessionId ?? `${label}-new` + sessions.push(id) + return { id } + }), + listProcesses: vi.fn(async () => + sessions.map((id) => ({ + id, + cwd: '', + title: label + })) + ), + hasPty: vi.fn((id: string) => sessions.includes(id)), + probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), + write: vi.fn((id: string, data: string) => { + writes.push({ id, data }) + }), + writeWithSettlement: vi.fn(settledWriteStub()), + resize: vi.fn(), + setPtyBackgrounded: vi.fn(), + getBufferSnapshot: vi.fn(async () => null), + shutdown: vi.fn(async (id: string) => { + const idx = sessions.indexOf(id) + if (idx !== -1) { + sessions.splice(idx, 1) + } + }), + attach: vi.fn(async () => {}), + sendSignal: vi.fn(async () => {}), + getCwd: vi.fn(async () => ''), + getInitialCwd: vi.fn(async () => ''), + clearBuffer: vi.fn(async () => {}), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(async () => false), + getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), + confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), + serialize: vi.fn(async () => '{}'), + revive: vi.fn(async () => {}), + getDefaultShell: vi.fn(async () => '/bin/zsh'), + getProfiles: vi.fn(async () => []), + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } + } + } + ), + onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { + backgroundListeners.push(callback) + return () => { + const idx = backgroundListeners.indexOf(callback) + if (idx !== -1) { + backgroundListeners.splice(idx, 1) + } + } + }), + onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { + writeUnavailableListeners.push(callback) + return () => { + const idx = writeUnavailableListeners.indexOf(callback) + if (idx !== -1) { + writeUnavailableListeners.splice(idx, 1) + } + } + }), + onExit: vi.fn( + (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { + exitListeners.push(callback) + return () => { + const idx = exitListeners.indexOf(callback) + if (idx !== -1) { + exitListeners.splice(idx, 1) + } + } + } + ), + onDaemonIdentityChanged: vi.fn((callback: () => void) => { + identityChangeListeners.push(callback) + return () => { + const idx = identityChangeListeners.indexOf(callback) + if (idx !== -1) { + identityChangeListeners.splice(idx, 1) + } + } + }), + ackColdRestore: vi.fn(), + clearTombstone: vi.fn(), + reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), + dispose: vi.fn(), + disconnectOnly: vi.fn(async () => {}), + emitData: (id: string, data: string, sequenceChars?: number) => { + for (const listener of dataListeners) { + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) + } + }, + emitBackground: (event: PtyBackgroundStreamEvent) => { + for (const listener of backgroundListeners) { + listener(event) + } + }, + emitExit: (id: string, code: number, incarnationId?: string) => { + for (const listener of exitListeners) { + listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) + } + }, + emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), + triggerWriteUnavailable: (id: string) => { + for (const listener of writeUnavailableListeners) { + listener({ id }) + } + }, + _writes: writes + } as unknown as AdapterMock +} + it('forwards dead-endpoint write-unavailable signals from every routed adapter', () => { // Why revert-sensitive: main subscribes on the ROUTED provider, so if the router // does not forward this the STA-2373 fan-out never reaches the renderer and only @@ -92,83 +247,6 @@ it('forwards the owning legacy daemon sequence from attach', async () => { }) describe('DaemonPtyRouter', () => { - describe('idle retirement', () => { - it('retires every empty daemon generation and fences subsequent spawns', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - expect(current.requestIdleRetirement).toHaveBeenCalledOnce() - expect(legacy.requestIdleRetirement).toHaveBeenCalledOnce() - await expect(router.spawn({ sessionId: 'late', cols: 80, rows: 24 })).rejects.toThrow( - 'Terminal daemon is decommissioning' - ) - }) - - it('reports live inventory before retiring any generation and reopens admission', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['legacy-live'], undefined, PROTOCOL_VERSION) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - expect(current.requestIdleRetirement).not.toHaveBeenCalled() - expect(legacy.requestIdleRetirement).not.toHaveBeenCalled() - await expect( - router.spawn({ sessionId: 'after-refusal', cols: 80, rows: 24 }) - ).resolves.toEqual({ - id: 'after-refusal' - }) - }) - - it('does not partially retire when a generation predates clean idle shutdown', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, 23) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'unsupported' }) - expect(current.requestIdleRetirement).not.toHaveBeenCalled() - expect(legacy.requestIdleRetirement).not.toHaveBeenCalled() - }) - - it('keeps admission fenced after a partial multi-generation retirement', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 0 - }) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - await expect(router.spawn({ sessionId: 'unsafe', cols: 80, rows: 24 })).rejects.toThrow( - 'Terminal daemon is decommissioning' - ) - }) - - it('does not certify reopened admission when another generation retired beside live sessions', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - await expect(router.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1 - }) - await expect( - router.spawn({ sessionId: 'unsafe-partial', cols: 80, rows: 24 }) - ).rejects.toThrow('Terminal daemon is decommissioning') - }) - }) - it('reports separate conservative resume and fresh-create boundaries', () => { const current = createAdapter( 'current', diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 8b5b527cb31..0534c9d2869 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,4 +1,3 @@ -import { reconcileDaemonRouterSessions } from './daemon-router-session-reconciliation' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' import type { @@ -13,8 +12,6 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' -import { DaemonRouterRetirement } from './daemon-router-retirement' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' @@ -24,7 +21,6 @@ export class DaemonPtyRouter implements IPtyProvider { private sessionAdapters = new Map() private readonly ownerResolver: DaemonSessionOwnerResolver private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout - private readonly retirement = new DaemonRouterRetirement(() => this.allAdapters()) constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current @@ -44,34 +40,17 @@ export class DaemonPtyRouter implements IPtyProvider { } async spawn(opts: PtySpawnOptions): Promise { - if (this.retirement.admissionClosed) { - throw new Error('Terminal daemon is decommissioning') + if (opts.attachOnly && opts.sessionId) { + return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) } - // Why counted: an idle-retirement census must not race a spawn it cannot yet see. - this.retirement.spawnInFlight++ - try { - if (opts.attachOnly && opts.sessionId) { - return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) - } - const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined - const target = adapter ?? this.current - const result = await target.spawn(opts) - // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. - if (!result.exitedBeforeSpawnReply) { - this.ownerResolver.recordRoute(result.id, target, result.incarnationId) - } - return result - } finally { - this.retirement.spawnInFlight-- + const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined + const target = adapter ?? this.current + const result = await target.spawn(opts) + // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. + if (!result.exitedBeforeSpawnReply) { + this.ownerResolver.recordRoute(result.id, target, result.incarnationId) } - } - - requestIdleRetirement(): Promise { - return this.retirement.requestIdleRetirement() - } - - releaseIdleRetirementFence(): void { - this.retirement.releaseFence() + return result } supportsGitCredentialGuardHost(sessionId?: string): boolean { @@ -278,10 +257,38 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(sessionId).clearTombstone(sessionId) } - async reconcileOnStartup( - validWorktreeIds: Set - ): Promise<{ alive: string[]; killed: string[] }> { - return reconcileDaemonRouterSessions(this.allAdapters(), this.ownerResolver, validWorktreeIds) + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + const alive: string[] = [] + const killed: string[] = [] + const aliveProviders = new Map>() + for (const adapter of this.allAdapters()) { + const result = await adapter.reconcileOnStartup(validWorktreeIds) + // Why: daemon startup can reconcile many restored sessions; spreading + // those arrays into push can exceed JavaScript's argument limit. + for (const id of result.alive) { + alive.push(id) + } + for (const id of result.killed) { + killed.push(id) + } + for (const id of result.alive) { + const providers = aliveProviders.get(id) ?? new Set() + providers.add(adapter) + aliveProviders.set(id, providers) + } + } + for (const id of new Set([...alive, ...killed])) { + const providers = aliveProviders.get(id) + if (providers?.size === 1) { + this.ownerResolver.recordRoute(id, providers.values().next().value!) + } else { + this.ownerResolver.forgetRoute(id) + } + } + return { alive, killed } } dispose(): void { diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts index 2d773d21ade..38480af1489 100644 --- a/src/main/daemon/daemon-pty-runtime-state.ts +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -57,7 +57,6 @@ export type DaemonPtyAdapterOptions = { historyPath?: string runtimeDir?: string packagedAppVersion?: string | null - recoveryOnly?: boolean respawn?: (reason: DaemonRespawnReason) => Promise void)> } @@ -72,15 +71,8 @@ export type DaemonIdentityChangeEvent = { current: DaemonEndpointIdentity } -export type DaemonIdleRetirementResult = - | { state: 'retiring' } - | { state: 'busy'; liveSessions: number | null; admissionReopened?: true } - | { state: 'unsupported' } - | { state: 'unverifiable' } - export abstract class DaemonPtyRuntimeState { readonly protocolVersion: number - readonly recoveryOnly: boolean protected socketPath: string protected tokenPath: string protected pidPath: string | null @@ -100,9 +92,6 @@ export abstract class DaemonPtyRuntimeState { protected packagedAppVersion: string | null protected pendingRespawnAdoptionRelease: (() => void) | null = null protected respawnAdoptionClosed = false - protected idleRetirementAdmissionClosed = false - protected idleRetirementState: 'open' | 'checking' | 'retiring' | 'unverifiable' = 'open' - protected idleRetirementPromise: Promise | null = null protected respawnPromise: Promise | null = null protected staleBundleReplacementPromise: Promise | null = null protected writeRecoveryPromise: Promise | null = null @@ -201,7 +190,6 @@ export abstract class DaemonPtyRuntimeState { constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.recoveryOnly = opts.recoveryOnly === true this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath this.pidPath = opts.pidPath ?? null @@ -221,7 +209,7 @@ export abstract class DaemonPtyRuntimeState { }) this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null - this.respawnFn = this.recoveryOnly ? null : (opts.respawn ?? null) + this.respawnFn = opts.respawn ?? null this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null this.packagedAppVersion = opts.packagedAppVersion ?? null this.supportsCheckpoints = this.protocolVersion >= 4 diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index e7d04f5851e..388919dd2e3 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -22,15 +22,10 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { assertDaemonRecoverySpawnAdmission } from './daemon-recovery-spawn-admission' import { addWslEnvKeys } from '../wsl-env' export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { async spawn(opts: PtySpawnOptions): Promise { - assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts) - if (this.idleRetirementAdmissionClosed) { - throw new Error('Terminal daemon is decommissioning') - } const spawnOpts = this.withHistoryIsolation(opts) const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) const operation: PendingDaemonSpawnOperation = { @@ -110,10 +105,6 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { operation: PendingDaemonSpawnOperation, historyRecovery: HistoryRecoveryContext ): Promise { - assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts) - if (this.idleRetirementAdmissionClosed) { - throw new Error('Terminal daemon is decommissioning') - } if ( opts.agentSessionEnsure && this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION diff --git a/src/main/daemon/daemon-recovery-only-adapter.test.ts b/src/main/daemon/daemon-recovery-only-adapter.test.ts deleted file mode 100644 index ba3bf9be04d..00000000000 --- a/src/main/daemon/daemon-recovery-only-adapter.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { rmSync } from 'node:fs' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { - createMockSubprocess, - startDaemonAdapterHarness, - waitFor, - type DaemonAdapterHarness -} from './daemon-pty-adapter-test-harness' -import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' -import { DaemonPtyRouter } from './daemon-pty-router' - -let harness: DaemonAdapterHarness -let recovery: DaemonPtyAdapter -let subprocess: ReturnType -const spawn = vi.fn(() => subprocess) -const respawn = vi.fn(async () => {}) - -beforeEach(async () => { - spawn.mockClear() - respawn.mockClear() - subprocess = createMockSubprocess() - harness = await startDaemonAdapterHarness(spawn) - await harness.adapter.spawn({ sessionId: 'existing', cols: 80, rows: 24 }) - recovery = new DaemonPtyAdapter({ - socketPath: harness.socketPath, - tokenPath: harness.tokenPath, - recoveryOnly: true, - respawn - }) -}) - -afterEach(async () => { - recovery?.dispose() - harness.adapter.dispose() - await harness.server.shutdown() - rmSync(harness.dir, { recursive: true, force: true }) -}) - -it('reattaches and controls existing work without admitting a new process', async () => { - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ id: 'existing', isReattach: true }) - recovery.write('existing', 'still live\n') - await waitFor(() => subprocess.write.mock.calls.length > 0) - expect(subprocess.write).toHaveBeenCalledWith('still live\n') - await expect(recovery.spawn({ sessionId: 'fresh', cols: 80, rows: 24 })).rejects.toThrow( - 'managed-stop recovery' - ) - await expect( - recovery.spawn({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow() - expect(spawn).toHaveBeenCalledOnce() - expect(respawn).not.toHaveBeenCalled() -}) - -it('does not certify fresh admission after a confirmed native stop refusal', async () => { - await expect(recovery.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1 - }) - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(recovery.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() -}) - -it('does not invoke a supplied replacement launcher after endpoint loss', async () => { - await recovery.listProcesses() - await harness.server.shutdown() - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow() - expect(respawn).not.toHaveBeenCalled() -}) - -it('keeps recovery-only admission through routed inventory refusal', async () => { - const router = new DaemonPtyRouter({ current: recovery, legacy: [] }) - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'busy', liveSessions: 1 }) - await expect( - router.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(router.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() -}) - -it('rejects legacy attach emulation before contacting the daemon', async () => { - const legacy = new DaemonPtyAdapter({ - socketPath: harness.socketPath, - tokenPath: harness.tokenPath, - protocolVersion: STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - 1, - recoveryOnly: true - }) - try { - await expect( - legacy.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() - } finally { - legacy.dispose() - } -}) diff --git a/src/main/daemon/daemon-recovery-provider-init.ts b/src/main/daemon/daemon-recovery-provider-init.ts deleted file mode 100644 index 806c45efcf2..00000000000 --- a/src/main/daemon/daemon-recovery-provider-init.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rebindLocalProviderListeners } from '../ipc/pty' -import { getDaemonRuntimeDir, getDaemonHistoryDir } from './daemon-launch-paths' -import { createDaemonRecoveryProvider } from './daemon-recovery-provider' -import { installDaemonProvider } from './daemon-provider-state' - -export function initDaemonRecoveryProvider(): void { - const provider = createDaemonRecoveryProvider(getDaemonRuntimeDir(), getDaemonHistoryDir()) - installDaemonProvider(null, provider) - rebindLocalProviderListeners() -} diff --git a/src/main/daemon/daemon-recovery-provider.test.ts b/src/main/daemon/daemon-recovery-provider.test.ts deleted file mode 100644 index 17a53b370ea..00000000000 --- a/src/main/daemon/daemon-recovery-provider.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { createDaemonRecoveryProvider } from './daemon-recovery-provider' -import { getDaemonPidPath, getDaemonTokenPath } from './daemon-spawner' -import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS } from './types' -import { - createMockSubprocess, - startDaemonAdapterHarness, - type DaemonAdapterHarness -} from './daemon-pty-adapter-test-harness' -import type { DaemonPtyRouter } from './daemon-pty-router' - -let harness: DaemonAdapterHarness -let provider: DaemonPtyRouter | undefined -beforeEach(async () => { - harness = await startDaemonAdapterHarness(() => createMockSubprocess()) - copyFileSync(harness.tokenPath, getDaemonTokenPath(harness.dir)) -}) -afterEach(async () => { - provider?.dispose() - provider = undefined - harness.adapter.dispose() - await harness.server.shutdown() - rmSync(harness.dir, { recursive: true, force: true }) -}) - -it('recovers live terminals without pruning folder or unknown workspace sessions', async () => { - await harness.adapter.spawn({ sessionId: 'folder-terminal', cols: 80, rows: 24 }) - provider = createDaemonRecoveryProvider(harness.dir, join(harness.dir, 'history')) - expect(provider.getAllAdapters().every((entry) => entry.recoveryOnly)).toBe(true) - await expect(provider.reconcileOnStartup(new Set())).resolves.toEqual({ - alive: ['folder-terminal'], - killed: [] - }) - await expect( - provider.spawn({ sessionId: 'folder-terminal', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') -}) - -it('retains unreachable legacy generations and their credentials', async () => { - const version = PREVIOUS_DAEMON_PROTOCOL_VERSIONS[0] - const tokenPath = getDaemonTokenPath(harness.dir, version) - const pidPath = getDaemonPidPath(harness.dir, version) - writeFileSync(tokenPath, 'retained-secret') - writeFileSync(pidPath, '{unreadable pid') - provider = createDaemonRecoveryProvider(harness.dir, join(harness.dir, 'history')) - const legacy = provider.getAllAdapters().find((entry) => entry.protocolVersion === version) - expect(legacy?.recoveryOnly).toBe(true) - await expect(legacy!.listSessions()).rejects.toThrow() - expect(readFileSync(tokenPath, 'utf8')).toBe('retained-secret') - expect(readFileSync(pidPath, 'utf8')).toBe('{unreadable pid') -}) diff --git a/src/main/daemon/daemon-recovery-provider.ts b/src/main/daemon/daemon-recovery-provider.ts deleted file mode 100644 index c27b24a7151..00000000000 --- a/src/main/daemon/daemon-recovery-provider.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { lstatSync } from 'node:fs' -import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { DaemonPtyRouter } from './daemon-pty-router' -import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' -import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' - -function hasEndpointEvidence(path: string): boolean { - try { - lstatSync(path) - return true - } catch (error) { - // Unreadable evidence must keep the generation represented as unverifiable. - return !(error instanceof Error && 'code' in error && error.code === 'ENOENT') - } -} - -export function createDaemonRecoveryProvider( - runtimeDir: string, - historyPath: string -): DaemonPtyRouter { - const create = (protocolVersion: number): DaemonPtyAdapter => - new DaemonPtyAdapter({ - socketPath: getDaemonSocketPath(runtimeDir, protocolVersion), - tokenPath: getDaemonTokenPath(runtimeDir, protocolVersion), - pidPath: getDaemonPidPath(runtimeDir, protocolVersion), - profileScope: runtimeDir, - runtimeDir, - historyPath, - protocolVersion, - recoveryOnly: true - }) - const legacy = PREVIOUS_DAEMON_PROTOCOL_VERSIONS.filter((version) => - [ - getDaemonPidPath(runtimeDir, version), - getDaemonTokenPath(runtimeDir, version), - ...(process.platform === 'win32' ? [] : [getDaemonSocketPath(runtimeDir, version)]) - ].some(hasEndpointEvidence) - ).map(create) - // Represent the current endpoint even when absent; missing contact is not an empty census. - return new DaemonPtyRouter({ current: create(PROTOCOL_VERSION), legacy }) -} diff --git a/src/main/daemon/daemon-recovery-spawn-admission.ts b/src/main/daemon/daemon-recovery-spawn-admission.ts deleted file mode 100644 index f7e83869eb2..00000000000 --- a/src/main/daemon/daemon-recovery-spawn-admission.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { PtySpawnOptions } from '../providers/types' -import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' - -export function assertDaemonRecoverySpawnAdmission( - recoveryOnly: boolean, - protocolVersion: number, - opts: PtySpawnOptions -): void { - if (!recoveryOnly) { - return - } - // Legacy attach emulation can create before rejecting the result. - if ( - opts.attachOnly !== true || - !opts.sessionId || - opts.agentSessionEnsure || - protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - ) { - throw new Error('Terminal daemon admission is fenced for managed-stop recovery') - } -} diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 651eaf36ab6..d15514ea2ae 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -10,7 +10,6 @@ import type { DaemonSessionBackgroundRouting } from './daemon-session-background import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { DaemonTerminalAdmission } from './daemon-terminal-admission' -import { readDaemonHealthIdentity } from './daemon-health-identity' import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import type { TerminalHost } from './terminal-host' import { SessionNotFoundError, type DaemonRequest } from './types' @@ -157,7 +156,7 @@ export class DaemonRequestRouter { return { health: await readCurrentProcessMacSystemResolverHealth() } case 'ptySpawnHealth': await this.options.ptySpawnHealthCheck() - return { healthy: true, ...readDaemonHealthIdentity() } + return { healthy: true } case 'shutdown': return this.shutdown(clientId, request.id, request.payload.killSessions) } diff --git a/src/main/daemon/daemon-router-retirement.test.ts b/src/main/daemon/daemon-router-retirement.test.ts deleted file mode 100644 index 66e6e60172e..00000000000 --- a/src/main/daemon/daemon-router-retirement.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { DaemonRouterRetirement } from './daemon-router-retirement' -import { createAdapter } from './daemon-pty-router-test-fixture' -import { PROTOCOL_VERSION } from './types' - -it.each(['inventory', 'protocol', 'spawn', 'live'] as const)( - 'does not reopen admission on a %s retry after partial retirement', - async (failure) => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - let adapters = [current, legacy] - const retirement = new DaemonRouterRetirement(() => adapters) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 0 - }) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) - if (failure === 'inventory') { - vi.mocked(current.listSessions).mockRejectedValueOnce(new Error('lost connection')) - } else if (failure === 'protocol') { - adapters = [createAdapter('old', [], undefined, 23)] - } else if (failure === 'spawn') { - retirement.spawnInFlight = 1 - } else { - adapters = [createAdapter('live', ['existing'], undefined, PROTOCOL_VERSION)] - } - expect(await retirement.requestIdleRetirement()).not.toHaveProperty('admissionReopened') - expect(retirement.admissionClosed).toBe(true) - } -) - -it('does not reopen when every native result is busy without reopening proof', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockResolvedValue({ state: 'busy', liveSessions: 0 }) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) -}) - -it('keeps the fence through a lost native reply and failed retry inventory', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockRejectedValueOnce(new Error('lost stop reply')) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).rejects.toThrow('lost stop reply') - vi.mocked(current.listSessions).mockRejectedValueOnce(new Error('lost connection')) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) -}) - -it('releases a fence left by an incomplete retirement, but never one that retired', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockResolvedValueOnce({ state: 'busy', liveSessions: 0 }) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) - retirement.releaseFence() - expect(retirement.admissionClosed).toBe(false) - expect(current.releaseIdleRetirementFence).toHaveBeenCalledOnce() - - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - retirement.releaseFence() - expect(retirement.admissionClosed).toBe(true) -}) diff --git a/src/main/daemon/daemon-router-retirement.ts b/src/main/daemon/daemon-router-retirement.ts deleted file mode 100644 index 3ea17ab581a..00000000000 --- a/src/main/daemon/daemon-router-retirement.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' -import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' - -export class DaemonRouterRetirement { - admissionClosed = false - spawnInFlight = 0 - private retirementAttempted = false - private retired = false - private idleRetirementPromise: Promise | null = null - - constructor(private readonly allAdapters: () => DaemonPtyAdapter[]) {} - - /** Reopens a fence left by an attempt that did not retire every generation. */ - releaseFence(): void { - if (this.idleRetirementPromise || this.retired) { - return - } - this.admissionClosed = false - for (const adapter of this.allAdapters()) { - adapter.releaseIdleRetirementFence() - } - } - - async requestIdleRetirement(): Promise { - if (this.idleRetirementPromise) { - return this.idleRetirementPromise - } - this.admissionClosed = true - const request = this.finishIdleRetirementRequest().finally(() => { - if (this.idleRetirementPromise === request) { - this.idleRetirementPromise = null - } - }) - this.idleRetirementPromise = request - return request - } - - private async finishIdleRetirementRequest(): Promise { - const adapters = this.allAdapters() - if (this.spawnInFlight > 0) { - this.admissionClosed = this.retirementAttempted - return { state: 'busy', liveSessions: null } - } - if (adapters.some((adapter) => adapter.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION)) { - this.admissionClosed = this.retirementAttempted - return { state: 'unsupported' } - } - const inventories = await Promise.allSettled(adapters.map((adapter) => adapter.listSessions())) - if (inventories.some((inventory) => inventory.status === 'rejected')) { - this.admissionClosed = this.retirementAttempted - return { state: 'unverifiable' } - } - const liveSessions = inventories.reduce( - (count, inventory) => - count + - (inventory.status === 'fulfilled' - ? inventory.value.filter((session) => session.isAlive).length - : 0), - 0 - ) - if (liveSessions > 0) { - this.admissionClosed = this.retirementAttempted - return { - state: 'busy', - liveSessions, - ...(!this.retirementAttempted && !adapters.some((adapter) => adapter.recoveryOnly) - ? { admissionReopened: true as const } - : {}) - } - } - this.retirementAttempted = true - const results = await Promise.all(adapters.map((adapter) => adapter.requestIdleRetirement())) - if (results.every((result) => result.state === 'retiring')) { - this.retired = true - return { state: 'retiring' } - } - const refusedLiveSessions = results.reduce( - (count, result) => count + (result.state === 'busy' ? (result.liveSessions ?? 0) : 0), - 0 - ) - if (refusedLiveSessions > 0) { - return { state: 'busy', liveSessions: refusedLiveSessions } - } - return { state: 'unverifiable' } - } -} diff --git a/src/main/daemon/daemon-router-session-reconciliation.ts b/src/main/daemon/daemon-router-session-reconciliation.ts deleted file mode 100644 index d58487da106..00000000000 --- a/src/main/daemon/daemon-router-session-reconciliation.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' - -export async function reconcileDaemonRouterSessions( - adapters: readonly DaemonPtyAdapter[], - ownerResolver: DaemonSessionOwnerResolver, - validWorktreeIds: Set -): Promise<{ alive: string[]; killed: string[] }> { - const alive: string[] = [] - const killed: string[] = [] - const aliveProviders = new Map>() - for (const adapter of adapters) { - const result = await adapter.reconcileOnStartup(validWorktreeIds) - // Why: daemon startup can reconcile many restored sessions; spreading - // those arrays into push can exceed JavaScript's argument limit. - for (const id of result.alive) { - alive.push(id) - } - for (const id of result.killed) { - killed.push(id) - } - for (const id of result.alive) { - const providers = aliveProviders.get(id) ?? new Set() - providers.add(adapter) - aliveProviders.set(id, providers) - } - } - for (const id of new Set([...alive, ...killed])) { - const providers = aliveProviders.get(id) - if (providers?.size === 1) { - ownerResolver.recordRoute(id, providers.values().next().value!) - } else { - ownerResolver.forgetRoute(id) - } - } - return { alive, killed } -} diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 7d6f825b3f7..f10715e8711 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -118,20 +118,6 @@ describe('DaemonStreamDataBatcher', () => { }) }) - it('preserves PTY incarnation identity through stream serialization', () => { - const { batcher, streamSocket } = createBatcher() - - batcher.enqueue('client-1', 'session-1', 'output', { - flushImmediately: true, - incarnationId: 'incarnation-1' - }) - - expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({ - event: 'data', - payload: { data: 'output', incarnationId: 'incarnation-1' } - }) - }) - it('keeps large pending output batched even when an interactive redraw follows', () => { vi.useFakeTimers() try { diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 202d59c4009..a562a60ee9d 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher { this.maxLineBytes, sliceSequenceChars, entry.seq, - entry.transformed, - entry.incarnationId + entry.transformed ) } this.updateBackpressure(clientId, batch) diff --git a/src/main/daemon/daemon-stream-data-entry.ts b/src/main/daemon/daemon-stream-data-entry.ts index e4159aaa88a..017d21fde8c 100644 --- a/src/main/daemon/daemon-stream-data-entry.ts +++ b/src/main/daemon/daemon-stream-data-entry.ts @@ -2,7 +2,6 @@ import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' import { writeStreamDataEvents } from './daemon-stream-data-split' import { encodeNdjson } from './ndjson' import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DaemonStreamEnqueueOptions = { flushImmediately?: boolean @@ -10,7 +9,6 @@ export type DaemonStreamEnqueueOptions = { rawLength?: number transformed?: boolean seq?: number - incarnationId?: PtyIncarnationId } export function appendDaemonStreamData( @@ -25,8 +23,7 @@ export function appendDaemonStreamData( last?.sessionId === sessionId && !last.control && !last.transformed && - options.transformed !== true && - last.incarnationId === options.incarnationId + options.transformed !== true ) { last.data += data const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length @@ -42,8 +39,7 @@ export function appendDaemonStreamData( ? {} : { sequenceChars: options.rawLength }), ...(options.transformed ? { transformed: true } : {}), - ...(options.seq === undefined ? {} : { seq: options.seq }), - ...(options.incarnationId === undefined ? {} : { incarnationId: options.incarnationId }) + ...(options.seq === undefined ? {} : { seq: options.seq }) }) ) } @@ -95,8 +91,7 @@ export function flushDaemonStreamSession( maxLineBytes, entry.sequenceChars ?? entry.data.length, entry.seq, - entry.transformed, - entry.incarnationId + entry.transformed ) } } diff --git a/src/main/daemon/daemon-stream-data-split.test.ts b/src/main/daemon/daemon-stream-data-split.test.ts index 2fe9983252a..48cf8cbc80e 100644 --- a/src/main/daemon/daemon-stream-data-split.test.ts +++ b/src/main/daemon/daemon-stream-data-split.test.ts @@ -18,8 +18,7 @@ function write( rawLength = data.length, seq?: number, transformed = false, - sessionId = 'session-1', - incarnationId?: string + sessionId = 'session-1' ): string[] { const lines: string[] = [] const socket: Pick = { @@ -28,16 +27,7 @@ function write( return true }) } - writeStreamDataEvents( - socket, - sessionId, - data, - maxLineBytes, - rawLength, - seq, - transformed, - incarnationId - ) + writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed) return lines } @@ -94,15 +84,6 @@ describe('writeStreamDataEvents serialization budget', () => { expect(encodeNdjson).toHaveBeenCalledTimes(1) }) - it('retains incarnation metadata in the single-encode fast path', () => { - const line = encodeStreamDataEvent('session-1', 'é🐙', undefined, undefined, false, 'epoch-1') - vi.mocked(encodeNdjson).mockClear() - expect( - write('é🐙', Buffer.byteLength(line), 3, undefined, false, 'session-1', 'epoch-1') - ).toEqual([line]) - expect(encodeNdjson).toHaveBeenCalledTimes(1) - }) - it('does not add a duplicate full-data sizing probe to oversized writes', () => { const data = '🐙\x1b[0m'.repeat(100) const expected = previousWrites(data, 160) @@ -121,24 +102,6 @@ describe('writeStreamDataEvents serialization budget', () => { }) describe('writeStreamDataEvents wire parity', () => { - it.each([undefined, 9000])('budgets incarnation metadata on oversized writes (seq=%s)', (seq) => { - const data = '🐙é中\x1b[0m"\\\n'.repeat(100) - const incarnationId = 'epoch-'.repeat(16) - const lines = write(data, 384, data.length, seq, false, 'session-1', incarnationId) - expect(lines.length).toBeGreaterThan(1) - let consumed = 0 - const chunks = lines.map((line) => { - expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(384) - const { payload } = JSON.parse(line) - expect(payload.incarnationId).toBe(incarnationId) - expect(typeof payload.data).toBe('string') - consumed += payload.data.length - expect(payload.seq).toBe(seq === undefined ? undefined : seq - data.length + consumed) - return payload.data - }) - expect(chunks.join('')).toBe(data) - }) - it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => { const payloads = [ '', diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts index b644d971e4b..c31c4437b84 100644 --- a/src/main/daemon/daemon-stream-data-split.ts +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -5,15 +5,13 @@ */ import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan' import { encodeNdjson } from './ndjson' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' export function encodeStreamDataEvent( sessionId: string, data: string, rawLength?: number, seq?: number, - transformed?: boolean, - incarnationId?: PtyIncarnationId + transformed?: boolean ): string { return encodeNdjson({ type: 'event', @@ -21,7 +19,6 @@ export function encodeStreamDataEvent( sessionId, payload: { data, - ...(incarnationId === undefined ? {} : { incarnationId }), ...(seq === undefined ? {} : { seq }), ...(rawLength === undefined ? {} : { rawLength }), ...(rawLength === undefined ? {} : { sequenceChars: rawLength }), @@ -30,16 +27,8 @@ export function encodeStreamDataEvent( }) } -function streamDataEventLineBytes( - sessionId: string, - data: string, - rawLength?: number, - incarnationId?: PtyIncarnationId -): number { - return Buffer.byteLength( - encodeStreamDataEvent(sessionId, data, rawLength, undefined, false, incarnationId), - 'utf8' - ) +function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8') } function isHighSurrogate(value: number): boolean { @@ -90,28 +79,20 @@ export function splitStreamDataForNdjson( sessionId: string, data: string, maxLineBytes: number, - sequenceChars?: number, - incarnationId?: PtyIncarnationId + sequenceChars?: number ): string[] { - if (streamDataEventLineBytes(sessionId, data, sequenceChars, incarnationId) <= maxLineBytes) { + if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) { return [data] } - return splitOversizedStreamDataForNdjson( - sessionId, - data, - maxLineBytes, - sequenceChars, - incarnationId - ) + return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars) } function splitOversizedStreamDataForNdjson( sessionId: string, data: string, maxLineBytes: number, - sequenceChars?: number, - incarnationId?: PtyIncarnationId + sequenceChars?: number ): string[] { const chunks: string[] = [] let start = 0 @@ -129,8 +110,7 @@ function splitOversizedStreamDataForNdjson( } if ( - streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars, incarnationId) <= - maxLineBytes + streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes ) { best = mid low = rawMid + 1 @@ -154,36 +134,28 @@ export function writeStreamDataEvents( maxLineBytes: number, rawLength = data.length, seq?: number, - transformed = false, - incarnationId?: PtyIncarnationId + transformed = false ): void { const explicitRawLength = rawLength === data.length ? undefined : rawLength if (transformed) { - streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true, incarnationId)) + streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true)) return } const carriesMetadata = explicitRawLength !== undefined || seq !== undefined let chunks: string[] if (!carriesMetadata) { - const line = encodeStreamDataEvent(sessionId, data, undefined, undefined, false, incarnationId) + const line = encodeStreamDataEvent(sessionId, data) if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) { streamSocket.write(line) return } - chunks = splitOversizedStreamDataForNdjson( - sessionId, - data, - maxLineBytes, - undefined, - incarnationId - ) + chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes) } else { chunks = splitStreamDataForNdjson( sessionId, data, Math.max(1, maxLineBytes - 96), - explicitRawLength, - incarnationId + explicitRawLength ) } let consumed = 0 @@ -191,8 +163,6 @@ export function writeStreamDataEvents( consumed += chunk.length const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed) const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined - streamSocket.write( - encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq, false, incarnationId) - ) + streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq)) } } diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index ede46bff133..55e9f053316 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -9,7 +9,6 @@ export type DataEvent = { sessionId: string payload: { data: string - incarnationId?: PtyIncarnationId seq?: number rawLength?: number transformed?: boolean diff --git a/src/main/daemon/daemon-stream-incarnation.test.ts b/src/main/daemon/daemon-stream-incarnation.test.ts deleted file mode 100644 index e52a49c444a..00000000000 --- a/src/main/daemon/daemon-stream-incarnation.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { appendDaemonStreamData, flushDaemonStreamSession } from './daemon-stream-data-entry' -import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' -import { SessionOutputPlane } from './session-output-plane' - -function batch(): PendingStreamDataBatch { - return { - timer: null, - queue: [], - queuedChars: 0, - queuedCharsBySession: new Map(), - queuedMetadataBytesBySession: new Map(), - droppableQueuedSessionIds: new Set() - } -} - -describe('daemon stream incarnation ids', () => { - it('never merges output from two incarnations into one queued entry', () => { - const pending = batch() - appendDaemonStreamData(pending, 'session-1', 'old', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'more', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'new', { incarnationId: 'incarnation-2' }) - - expect(pending.queue.map(({ data, incarnationId }) => ({ data, incarnationId }))).toEqual([ - { data: 'oldmore', incarnationId: 'incarnation-1' }, - { data: 'new', incarnationId: 'incarnation-2' } - ]) - }) - - it('writes the id on per-session flushes and omits it when absent', () => { - const pending = batch() - appendDaemonStreamData(pending, 'session-1', 'tagged', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'legacy', {}) - const lines: string[] = [] - flushDaemonStreamSession(pending, 'session-1', 64 * 1024, (line) => lines.push(line)) - - const payloads = lines.map((line) => JSON.parse(line).payload) - expect(payloads).toEqual([ - { data: 'tagged', incarnationId: 'incarnation-1' }, - { data: 'legacy' } - ]) - }) - - it('hands the session incarnation only to identity-aware clients', () => { - const plane = new SessionOutputPlane({ cols: 80, rows: 24, incarnationId: 'incarnation-1' }) - const legacy = vi.fn() - const aware = vi.fn() - const unused = vi.fn() - plane.attachClient({ onData: legacy, onExit: vi.fn() }) - plane.attachClient({ onData: unused, onDataWithIncarnation: aware, onExit: vi.fn() }) - - plane.emit({ data: 'hi', rawStartSeq: 0, rawEndSeq: 2, transformed: false }) - - expect(legacy).toHaveBeenCalledWith('hi') - expect(aware).toHaveBeenCalledWith('hi', undefined, undefined, undefined, 'incarnation-1') - expect(unused).not.toHaveBeenCalled() - plane.disposeEmulator() - }) -}) diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts index 5fcfeed09a0..7bbd20047fe 100644 --- a/src/main/daemon/daemon-stream-keep-tail-drop.ts +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -14,7 +14,6 @@ import { accountDaemonStreamEntry, releaseDaemonStreamEntry } from './daemon-stream-entry-accounting' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' // A control entry carries a whole pre-shaped stream event (background marker, // data gap, transient fact) that must ride at its exact position in the @@ -28,7 +27,6 @@ export type StreamQueueEntry = { sequenceChars?: number seq?: number transformed?: boolean - incarnationId?: PtyIncarnationId control?: DaemonEvent retainedBytes?: number } diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 984cbc87dec..f02cbdb9205 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -173,32 +173,23 @@ export class DaemonTerminalAdmission { clientId: string, sessionId: () => string ): CreateOrAttachOptions['streamClient'] { - const onData = ( - data: string, - rawLength = data.length, - transformed = false, - seq?: number, - incarnationId?: string - ): void => { - const routedSessionId = sessionId() - this.options.transientFactRelay.onSessionData(routedSessionId, data) - const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) - const isInteractiveOutput = - data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS - this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, - rawLength, - transformed, - seq, - ...(incarnationId === undefined ? {} : { incarnationId }) - }) - } return { - onData, - onDataWithIncarnation: onData, + onData: (data, rawLength = data.length, transformed = false, seq) => { + const routedSessionId = sessionId() + this.options.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) + const isInteractiveOutput = + data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS + this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq + }) + }, onExit: (code, incarnationId, cause) => { const routedSessionId = sessionId() this.options.log.log('session-exited', { diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts index 819b9c72259..f67d2e15b92 100644 --- a/src/main/daemon/session-output-pipeline.ts +++ b/src/main/daemon/session-output-pipeline.ts @@ -1,7 +1,6 @@ import { SessionOutputPlane } from './session-output-plane' import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import type { SubprocessHandle } from './session-subprocess-handle' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' /** The session's ordered output pipeline: the recovery barrier feeding the * output plane. Built together because the barrier's owner is what the @@ -12,7 +11,6 @@ export function createSessionOutputPipeline(opts: { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined - incarnationId?: PtyIncarnationId | undefined subprocess: SubprocessHandle isAlive: () => boolean }): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { @@ -24,7 +22,6 @@ export function createSessionOutputPipeline(opts: { scrollback: opts.scrollback, wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, - incarnationId: opts.incarnationId, getTerminalOwner: () => barrier?.getOwner() }) const recoveryBarrier = new TerminalShellRecoveryBarrier({ diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index d994fdf4b48..04b3f773bda 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -5,7 +5,6 @@ import { normalizePtySize } from './daemon-pty-size' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types' import type { TerminalOwner } from '../../shared/terminal-owner' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { SubprocessHandle } from './session-subprocess-handle' import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' @@ -16,14 +15,6 @@ const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 export type AttachedClient = { token: symbol onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - /** Identity-bearing callback used by mutation-aware consumers; legacy clients keep the old shape. */ - onDataWithIncarnation?: ( - data: string, - rawLength: number | undefined, - transformed: boolean | undefined, - seq: number | undefined, - incarnationId: PtyIncarnationId - ) => void onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void } @@ -33,7 +24,6 @@ export type SessionOutputPlaneOptions = { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined - incarnationId?: PtyIncarnationId | undefined /** Read from the recovery barrier at snapshot time; the barrier scans bytes * before this plane receives them, so its owner never lags the emulator. */ getTerminalOwner?: (() => TerminalOwner | undefined) | undefined @@ -45,7 +35,6 @@ export class SessionOutputPlane { readonly historySeeded: boolean | undefined private readonly emulator: HeadlessEmulator private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined - private readonly incarnationId: PtyIncarnationId | undefined private attachedClients: AttachedClient[] = [] private pendingOutputRecords: PendingOutputRecord[] = [] private pendingOutputBytes = 0 @@ -75,7 +64,6 @@ export class SessionOutputPlane { ? undefined : opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk)) this.readTerminalOwner = opts.getTerminalOwner - this.incarnationId = opts.incarnationId } get responderParser(): HeadlessEmulator['responderParser'] { @@ -209,7 +197,9 @@ export class SessionOutputPlane { return } this.record({ kind: 'output', data: pending }) - this.broadcastData(pending, 0, true, this._outputSequence) + for (const client of this.attachedClients) { + client.onData(pending, 0, true, this._outputSequence) + } } emit(emission: PtyIngressEmission): void { @@ -226,24 +216,9 @@ export class SessionOutputPlane { } // Broadcast to attached clients - if (emission.transformed || rawLength !== data.length) { - this.broadcastData(data, rawLength, true, this._outputSequence) - } else { - this.broadcastData(data) - } - } - - private broadcastData( - data: string, - rawLength?: number, - transformed?: boolean, - seq?: number - ): void { for (const client of this.attachedClients) { - if (client.onDataWithIncarnation && this.incarnationId) { - client.onDataWithIncarnation(data, rawLength, transformed, seq, this.incarnationId) - } else if (transformed || rawLength !== undefined || seq !== undefined) { - client.onData(data, rawLength, transformed, seq) + if (emission.transformed || rawLength !== data.length) { + client.onData(data, rawLength, true, this._outputSequence) } else { client.onData(data) } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7c63dbd1d3f..7d113665292 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -12,7 +12,12 @@ import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import type * as SessionProtocol from './types' +import type { + SessionState, + ShellReadyState, + TakePendingOutputResult, + TerminalSnapshot +} from './types' import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' @@ -23,7 +28,7 @@ export class Session { readonly launchAgent: TuiAgent | null readonly wslDistro: string | null readonly processNameIsSpawnFile: boolean - private _state: SessionProtocol.SessionState = 'running' + private _state: SessionState = 'running' private _exitCode: number | null = null private _disposed = false private subprocess: SubprocessHandle @@ -50,8 +55,7 @@ export class Session { wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, subprocess: this.subprocess, - isAlive: () => !this._disposed && this._state !== 'exited', - incarnationId: this.incarnationId + isAlive: () => !this._disposed && this._state !== 'exited' }) this.output = pipeline.output this.recoveryBarrier = pipeline.recoveryBarrier @@ -91,11 +95,11 @@ export class Session { this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } - get state(): SessionProtocol.SessionState { + get state(): SessionState { return this._state } - get shellState(): SessionProtocol.ShellReadyState { + get shellState(): ShellReadyState { return this.shellReady.state } @@ -212,7 +216,7 @@ export class Session { this.producerPause.release({ resume: true }) } - getSnapshot(opts: { scrollbackRows?: number } = {}): SessionProtocol.TerminalSnapshot | null { + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { this.startupIngress.snapshotBarrier() return this.output.getSnapshot(opts) } @@ -228,7 +232,7 @@ export class Session { takePendingOutput( includeSnapshot: boolean, opts: { teardownSnapshot?: boolean } = {} - ): SessionProtocol.TakePendingOutputResult | null { + ): TakePendingOutputResult | null { if (this._disposed) { return null } diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 654df23af81..9f6e6fbec1b 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -37,13 +37,6 @@ export type CreateOrAttachOptions = { } streamClient: { onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - onDataWithIncarnation?: ( - data: string, - rawLength: number | undefined, - transformed: boolean | undefined, - seq: number | undefined, - incarnationId: PtyIncarnationId - ) => void onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void } /** Lets the daemon route output under the adopted owner's canonical id before diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index e573b4ecd7f..05e849327b7 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -29,8 +29,7 @@ async function syncDirectory(directory: string): Promise { } } -/** Sync variant of the best-effort directory fsync, for callers that publish by rename or link. */ -export function syncDirectoryDurablySync(directory: string): void { +function syncDirectorySync(directory: string): void { let fd: number | null = null try { fd = openSync(directory, 'r') @@ -51,7 +50,7 @@ export function syncDirectoryDurablySync(directory: string): void { /** Rename an already-fsynced file and make the containing directory durable. */ export function renameDurableSync(tmpPath: string, finalPath: string): void { renameFileWithWindowsRetry(tmpPath, finalPath) - syncDirectoryDurablySync(dirname(finalPath)) + syncDirectorySync(dirname(finalPath)) } /** Publish an already-fsynced file without replacing a concurrently created destination. */ @@ -59,7 +58,7 @@ export function publishFileDurableSync(tmpPath: string, finalPath: string): bool if (!publishFileWithoutOverwrite(tmpPath, finalPath)) { return false } - syncDirectoryDurablySync(dirname(finalPath)) + syncDirectorySync(dirname(finalPath)) rmSync(tmpPath) return true } @@ -210,14 +209,12 @@ export async function removeStaleDurableWriteTempFiles( export function writeFileDurableSync( tmpPath: string, finalPath: string, - payload: string | Uint8Array, - /** Creation mode for a new file, e.g. 0o600 for state other users must not read. */ - mode?: number + payload: string | Uint8Array ): void { let renamed = false try { // A Uint8Array payload is written verbatim; a string still defaults to UTF-8. - writeFileSync(tmpPath, payload, mode === undefined ? undefined : { mode }) + writeFileSync(tmpPath, payload) const fd = openSync(tmpPath, 'r+') try { fsyncSync(fd) diff --git a/src/main/ipc/filesystem-import-ssh-remote-existence.ts b/src/main/ipc/filesystem-import-ssh-remote-existence.ts deleted file mode 100644 index e853f291f80..00000000000 --- a/src/main/ipc/filesystem-import-ssh-remote-existence.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IFilesystemProvider } from '../providers/types' - -/** Whether a remote path exists; only a definite "missing" answer reads as false. */ -export async function remotePathExists( - provider: IFilesystemProvider, - remotePath: string -): Promise { - try { - await provider.stat(remotePath) - return true - } catch (error) { - if (isRemoteMissingError(error)) { - return false - } - throw error - } -} - -function isRemoteMissingError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return ( - ('code' in error && error.code === 'ENOENT') || - /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( - error.message - ) - ) -} diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 39b2350bb44..9268c6ece24 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -8,8 +8,6 @@ import type { FileUploadSession, IFilesystemProvider } from '../providers/types' import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { runSshProviderContinuation } from '../ssh/ssh-provider-continuations' -import { remotePathExists } from './filesystem-import-ssh-remote-existence' import { captureLocalUploadRoot, preScanSshImportDirectory, @@ -19,12 +17,6 @@ import { // Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so // system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows. export async function importExternalPathsSsh( - ...args: Parameters -): Promise<{ results: ImportItemResult[] }> { - return runSshProviderContinuation(args[2], () => importExternalPathsSshTracked(...args)) -} - -async function importExternalPathsSshTracked( sourcePaths: string[], destDir: string, connectionId: string, @@ -277,3 +269,31 @@ async function ensureDropStagingDir( assertCurrent?.() await provider.createDir(destDir) } + +async function remotePathExists( + provider: IFilesystemProvider, + remotePath: string +): Promise { + try { + await provider.stat(remotePath) + return true + } catch (error) { + if (isRemoteMissingError(error)) { + return false + } + throw error + } +} + +function isRemoteMissingError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = (error as NodeJS.ErrnoException).code + return ( + code === 'ENOENT' || + /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( + error.message + ) + ) +} diff --git a/src/main/ipc/filesystem-provider-continuations.test.ts b/src/main/ipc/filesystem-provider-continuations.test.ts deleted file mode 100644 index c585d7ac6f6..00000000000 --- a/src/main/ipc/filesystem-provider-continuations.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { IFilesystemProvider } from '../providers/types' -import type { FilesystemHandlerContext } from './filesystem/filesystem-handler-context' -import { hasSshProviderContinuations } from '../ssh/ssh-provider-continuations' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { - advanceSshConnectionGeneration, - resetSshConnectionGenerations -} from '../ssh/ssh-connection-generation' -import { importExternalPathsSsh } from './filesystem-import-ssh' -import { captureLocalUploadRoot, uploadSshImportDirectory } from './filesystem-import-ssh-directory' -import { registerFilesystemWriteHandlers } from './filesystem/filesystem-write-handlers' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - lstat: vi.fn(), - writeFile: vi.fn(), - trashItem: vi.fn(), - resolveAuthorizedPath: vi.fn(), - tryDeleteWslUncPath: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle }, shell: mocks })) -vi.mock('node:fs/promises', () => ({ lstat: mocks.lstat, writeFile: mocks.writeFile })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: vi.fn(), - resolveAuthorizedPath: mocks.resolveAuthorizedPath -})) -vi.mock('./filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() })) -vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: mocks.tryDeleteWslUncPath })) -vi.mock('./ssh', () => ({ - getSshConnectionManager: () => ({ - getConnection: () => ({ getState: () => ({ status: 'connected' }) }) - }) -})) -vi.mock('./filesystem-import-ssh-directory', () => ({ - captureLocalUploadRoot: vi.fn(), - preScanSshImportDirectory: vi.fn(), - uploadSshImportDirectory: vi.fn() -})) - -const targetId = 'filesystem-continuation-target' -// The IPC payload fields these handlers read; each test passes the subset its channel needs. -type FilesystemHandlerArgs = Record -const handlers = new Map< - string, - (_event: unknown, args: FilesystemHandlerArgs) => Promise ->() -const fileStat = { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - -beforeEach(() => { - vi.resetAllMocks() - resetSshConnectionGenerations() - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(false) - handlers.clear() - mocks.handle.mockImplementation((channel, handler) => handlers.set(channel, handler)) - mocks.lstat.mockResolvedValue(fileStat) - mocks.resolveAuthorizedPath.mockImplementation(async (path) => path) - mocks.tryDeleteWslUncPath.mockResolvedValue(false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write handlers read no store field on the SSH branch under test. - registerFilesystemWriteHandlers({ store: {} } as FilesystemHandlerContext) -}) - -describe('SSH filesystem mutation continuation settlement', () => { - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s retains removed providers until settlement', - async (channel) => { - const pending = Promise.withResolvers() - const operation = vi.fn(() => { - expect(hasSshProviderContinuations(targetId)).toBe(true) - return pending.promise - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - writeFile: operation, - deletePath: operation - } as unknown as IFilesystemProvider) - const result = handlers.get(channel)!(null, { - connectionId: targetId, - expectedSshTargetId: targetId, - expectedSshConnectionGeneration: 0, - filePath: '/remote/file', - content: 'text', - targetPath: '/remote/file', - recursive: true - }) - unregisterSshFilesystemProvider(targetId) - advanceSshConnectionGeneration(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(hasSshProviderContinuations('other-target')).toBe(false) - pending.resolve() - await result - expect(hasSshProviderContinuations(targetId)).toBe(false) - expect(operation).toHaveBeenCalledWith( - ...(channel === 'fs:writeFile' ? ['/remote/file', 'text'] : ['/remote/file', true]) - ) - } - ) - - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s releases after provider rejection', - async (channel) => { - const pending = Promise.withResolvers() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - writeFile: () => pending.promise, - deletePath: () => pending.promise - } as unknown as IFilesystemProvider) - const result = handlers.get(channel)!(null, { - connectionId: targetId, - expectedSshTargetId: targetId, - expectedSshConnectionGeneration: 0, - filePath: '/remote/file', - targetPath: '/remote/file', - content: '' - }) - const failure = expect(result).rejects.toThrow('late failure') - expect(hasSshProviderContinuations(targetId)).toBe(true) - pending.reject(new Error('late failure')) - await failure - expect(hasSshProviderContinuations(targetId)).toBe(false) - } - ) - - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s leaves local handling untracked', - async (channel) => { - const pending = Promise.withResolvers() - mocks.writeFile.mockReturnValue(pending.promise) - mocks.trashItem.mockReturnValue(pending.promise) - const result = handlers.get(channel)!(null, { - filePath: '/local/file', - targetPath: '/local/file', - content: 'text' - }) - expect(hasSshProviderContinuations(targetId)).toBe(false) - pending.resolve() - await result - expect(channel === 'fs:writeFile' ? mocks.writeFile : mocks.trashItem).toHaveBeenCalled() - expect(hasSshProviderContinuations(targetId)).toBe(false) - } - ) -}) - -describe('complete SSH import continuation settlement', () => { - it('retains a failed directory import through pending rollback', async () => { - const rollback = Promise.withResolvers() - const rollingBack = Promise.withResolvers() - mocks.lstat.mockResolvedValue({ ...fileStat, isDirectory: () => true, isFile: () => false }) - vi.mocked(captureLocalUploadRoot).mockResolvedValue('/source/directory') - vi.mocked(uploadSshImportDirectory).mockRejectedValue(new Error('upload failed')) - const close = vi.fn(() => expect(hasSshProviderContinuations(targetId)).toBe(true)) - const deletePath = vi.fn(() => { - rollingBack.resolve() - return rollback.promise - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - openFileUploadSession: async () => ({ close }), - stat: async () => { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }, - createDirNoClobber: async () => {}, - deletePath - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/directory'], '/remote', targetId) - await rollingBack.promise - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(close).not.toHaveBeenCalled() - rollback.resolve() - expect((await result).results[0]).toMatchObject({ status: 'failed', reason: 'upload failed' }) - expect(deletePath).toHaveBeenCalledWith('/remote/directory', true) - expect(close).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - - it('retains tracking across local inspection, provider removal and session close', async () => { - const inspection = Promise.withResolvers() - const inspected = Promise.withResolvers() - const uploaded = Promise.withResolvers() - const upload = Promise.withResolvers() - mocks.lstat.mockImplementation(() => { - inspected.resolve() - return inspection.promise - }) - const close = vi.fn(() => expect(hasSshProviderContinuations(targetId)).toBe(true)) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - openFileUploadSession: async () => ({ - close, - uploadFile: () => { - uploaded.resolve() - return upload.promise - } - }), - stat: async () => { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - } - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/file'], '/remote', targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - await inspected.promise - unregisterSshFilesystemProvider(targetId) - advanceSshConnectionGeneration(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - inspection.resolve(fileStat) - await uploaded.promise - expect(hasSshProviderContinuations(targetId)).toBe(true) - upload.resolve() - expect((await result).results[0].status).toBe('imported') - expect(close).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - - it('includes staging preparation and releases on its failure', async () => { - const staging = Promise.withResolvers() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - createDir: () => { - expect(hasSshProviderContinuations(targetId)).toBe(true) - return staging.promise - } - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/file'], '/remote/.orca/drops', targetId, { - ensureDir: true - }) - const failure = expect(result).rejects.toThrow('staging failed') - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - staging.reject(new Error('staging failed')) - await failure - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) -}) diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 0cc5a921b45..07d4de5b56a 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -2,7 +2,6 @@ import { ipcMain, shell } from 'electron' import { lstat, writeFile } from 'node:fs/promises' import type { SshMutationExpectation } from '../../../shared/ssh-types' import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' -import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' @@ -27,9 +26,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - return runSshProviderContinuation(args.connectionId, () => - provider.writeFile(args.filePath, args.content) - ) + return provider.writeFile(args.filePath, args.content) } const filePath = await resolveAuthorizedPath(args.filePath, store) try { @@ -64,9 +61,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - return runSshProviderContinuation(args.connectionId, () => - provider.deletePath(args.targetPath, args.recursive) - ) + return provider.deletePath(args.targetPath, args.recursive) } // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). const targetPath = await resolveAuthorizedPath(args.targetPath, store, { diff --git a/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts b/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts deleted file mode 100644 index cc15058c7f8..00000000000 --- a/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - deploy: vi.fn(), - status: vi.fn(), - registerProvisioning: vi.fn() -})) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-runtime-lifecycle', () => ({ - createManagedOrcadEnvironment: mocks.deploy, - getManagedOrcadRuntimeStatus: mocks.status -})) -vi.mock('./orcad-ssh-provisioning-handlers', () => ({ - registerOrcadSshProvisioningHandlers: mocks.registerProvisioning -})) - -const { registerOrcadRuntimeLifecycleHandlers } = await import('./orcad-runtime-lifecycle-handlers') - -function handler(channel: string): (_event: unknown, args: unknown) => unknown { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -describe('managed orcad lifecycle IPC', () => { - beforeEach(() => { - vi.clearAllMocks() - registerOrcadRuntimeLifecycleHandlers({ getUserDataPath: () => '/profile' }) - }) - - it('registers only deploy, status and provisioning; maintenance and stop are not exposed', () => { - expect(mocks.handle.mock.calls.map(([channel]) => channel)).toEqual([ - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus' - ]) - expect(mocks.registerProvisioning).toHaveBeenCalledOnce() - }) - - it('trims deploy input and treats only a literal true as force', async () => { - await handler('runtimeEnvironments:deployOrcad')(null, { - name: ' Managed ', - sshTargetId: ' ssh-1 ', - force: 'yes' - }) - expect(mocks.deploy).toHaveBeenCalledWith('/profile', { - name: 'Managed', - sshTargetId: 'ssh-1', - force: false - }) - }) - - it('rejects missing selectors before touching SSH', async () => { - await expect(handler('runtimeEnvironments:deployOrcad')(null, { name: 'x' })).rejects.toThrow( - 'SSH target is required' - ) - expect(() => handler('runtimeEnvironments:getOrcadStatus')(null, undefined)).toThrow( - 'Server is required' - ) - expect(mocks.deploy).not.toHaveBeenCalled() - expect(mocks.status).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/orcad-runtime-lifecycle-handlers.ts b/src/main/ipc/orcad-runtime-lifecycle-handlers.ts deleted file mode 100644 index 4d6e23833b1..00000000000 --- a/src/main/ipc/orcad-runtime-lifecycle-handlers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ipcMain } from 'electron' -import type { OrcadManagedRuntimeStatus } from '../../shared/orcad-managed-runtime' -import { - createManagedOrcadEnvironment, - getManagedOrcadRuntimeStatus -} from '../ssh/orcad-runtime-lifecycle' -import { registerOrcadSshProvisioningHandlers } from './orcad-ssh-provisioning-handlers' - -export function registerOrcadRuntimeLifecycleHandlers(options: { - getUserDataPath: () => string -}): void { - registerOrcadSshProvisioningHandlers(options.getUserDataPath) - ipcMain.handle( - 'runtimeEnvironments:deployOrcad', - async (_event, args: { name: string; sshTargetId: string; force?: boolean }) => - createManagedOrcadEnvironment(options.getUserDataPath(), { - name: requiredString(args?.name, 'Server name'), - sshTargetId: requiredString(args?.sshTargetId, 'SSH target'), - force: args?.force === true - }) - ) - ipcMain.handle( - 'runtimeEnvironments:getOrcadStatus', - (_event, args: { selector: string }): Promise => - getManagedOrcadRuntimeStatus( - options.getUserDataPath(), - requiredString(args?.selector, 'Server') - ) - ) -} - -export function requiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`${label} is required.`) - } - return value.trim() -} diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts deleted file mode 100644 index ad87a398cc7..00000000000 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - update: vi.fn(), - rollback: vi.fn(), - recover: vi.fn(), - stop: vi.fn(), - cancel: vi.fn(), - retire: vi.fn() -})) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-runtime-lifecycle', () => ({ - updateManagedOrcadEnvironment: mocks.update, - rollbackManagedOrcadEnvironment: mocks.rollback, - recoverManagedOrcadEnvironment: mocks.recover, - stopManagedOrcadEnvironment: mocks.stop, - cancelManagedOrcadStop: mocks.cancel -})) -vi.mock('./runtime-environment-removal-cleanup', () => ({ - retireRemovedRuntimeEnvironment: mocks.retire -})) - -const { registerOrcadRuntimeMaintenanceHandlers } = - await import('./orcad-runtime-maintenance-handlers') - -const invalidateTransport = vi.fn() - -function handler(channel: string): (_event: unknown, args: unknown) => Promise { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -describe('managed orcad maintenance IPC', () => { - beforeEach(() => { - vi.clearAllMocks() - registerOrcadRuntimeMaintenanceHandlers({ - getUserDataPath: () => '/profile', - getActiveEnvironmentId: () => 'active-environment', - invalidateTransport - }) - }) - - it('reconnects after an update restarts orcad, but not after a deferral', async () => { - mocks.update.mockResolvedValueOnce({ outcome: 'deferred', code: 'busy' }) - await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: 'yes' }) - expect(mocks.update).toHaveBeenCalledWith('/profile', { selector: 'Managed', force: false }) - expect(invalidateTransport).not.toHaveBeenCalled() - mocks.update.mockResolvedValueOnce({ outcome: 'updated', environment: { id: 'e-1' } }) - await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true }) - expect(invalidateTransport).toHaveBeenCalledWith('e-1') - }) - - it('reconnects after rollback and after recovery restores a serving slot', async () => { - mocks.rollback.mockResolvedValueOnce({ outcome: 'rolled-back', environment: { id: 'e-1' } }) - await handler('runtimeEnvironments:rollbackOrcad')(null, { selector: 'Managed' }) - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - activeVersion: null, - environment: { id: 'e-1' } - }) - await handler('runtimeEnvironments:recoverOrcad')(null, { selector: 'Managed' }) - expect(invalidateTransport).toHaveBeenCalledTimes(1) - }) - - it('stops with the Active Server guard and the shared removal cleanup', async () => { - mocks.stop.mockResolvedValueOnce({ outcome: 'unlinked' }) - await handler('runtimeEnvironments:stopOrcad')(null, { selector: ' Managed ' }) - const [, args, policy] = mocks.stop.mock.calls[0] ?? [] - expect(args).toEqual({ selector: 'Managed' }) - expect(policy.isActiveEnvironment('active-environment')).toBe(true) - expect(policy.isActiveEnvironment('e-1')).toBe(false) - policy.retireLocalState('e-1') - expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport) - }) - - it('rejects a missing selector before touching SSH', async () => { - await expect(handler('runtimeEnvironments:cancelOrcadStop')(null, {})).rejects.toThrow( - 'Server is required' - ) - expect(mocks.cancel).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.ts deleted file mode 100644 index 0f16da4de07..00000000000 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ipcMain } from 'electron' -import type { - OrcadManagedCancelStopResult, - OrcadManagedDeployResult, - OrcadManagedRecoveryResult, - OrcadManagedRollbackResult, - OrcadManagedStopResult -} from '../../shared/orcad-managed-runtime' -import { - cancelManagedOrcadStop, - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - stopManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} from '../ssh/orcad-runtime-lifecycle' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' -import { requiredString } from './orcad-runtime-lifecycle-handlers' - -export function registerOrcadRuntimeMaintenanceHandlers(options: { - getUserDataPath: () => string - getActiveEnvironmentId: () => string | null | undefined - invalidateTransport: (environmentId: string) => Promise | void -}): void { - ipcMain.handle( - 'runtimeEnvironments:updateOrcad', - async ( - _event, - args: { selector: string; force?: boolean } - ): Promise => { - const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server'), - force: args?.force === true - }) - // Why: a restarted orcad drops the old connection; reconnect on the new one. - if (result.outcome === 'updated') { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:rollbackOrcad', - async (_event, args: { selector: string }): Promise => { - const result = await rollbackManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - if (result.outcome === 'rolled-back') { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:recoverOrcad', - async (_event, args: { selector: string }): Promise => { - const result = await recoverManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - if (result.outcome === 'recovered' && result.activeVersion) { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:stopOrcad', - async (_event, args: { selector: string }): Promise => - stopManagedOrcadEnvironment( - options.getUserDataPath(), - { selector: requiredString(args?.selector, 'Server') }, - { - isActiveEnvironment: (environmentId) => - options.getActiveEnvironmentId() === environmentId, - retireLocalState: (environmentId) => - retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport) - } - ) - ) - ipcMain.handle( - 'runtimeEnvironments:cancelOrcadStop', - async (_event, args: { selector: string }): Promise => - cancelManagedOrcadStop(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - ) -} diff --git a/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts b/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts deleted file mode 100644 index 6d5fa72da34..00000000000 --- a/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - create: vi.fn(), - resume: vi.fn(), - list: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-ssh-provisioning', () => ({ - createOrcadSshHost: mocks.create, - resumeOrcadSshHost: mocks.resume, - listPendingOrcadSshProvisioning: mocks.list -})) -import { registerOrcadSshProvisioningHandlers } from './orcad-ssh-provisioning-handlers' - -describe('managed SSH provisioning IPC', () => { - beforeEach(() => vi.clearAllMocks()) - - it('registers typed create, resume and pending discovery without changing legacy SSH channels', async () => { - registerOrcadSshProvisioningHandlers(() => '/active-profile') - const handlers = new Map(mocks.handle.mock.calls.map(([name, handler]) => [name, handler])) - expect([...handlers.keys()]).toEqual([ - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning' - ]) - const request = { requestId: 'request-1', name: 'host', target: { host: 'builder' } } - const pending = { result: { outcome: 'pending', reason: 'unverifiable' } } - mocks.create.mockResolvedValue(pending) - expect(await handlers.get('runtimeEnvironments:createOrcadSshHost')!(null, request)).toBe( - pending - ) - expect(mocks.create).toHaveBeenCalledWith('/active-profile', request) - await handlers.get('runtimeEnvironments:resumeOrcadSshHost')!(null, { requestId: 'request-1' }) - expect(mocks.resume).toHaveBeenCalledWith('/active-profile', 'request-1') - handlers.get('runtimeEnvironments:listPendingOrcadSshProvisioning')!() - expect(mocks.list).toHaveBeenCalledWith('/active-profile') - }) -}) diff --git a/src/main/ipc/orcad-ssh-provisioning-handlers.ts b/src/main/ipc/orcad-ssh-provisioning-handlers.ts deleted file mode 100644 index 31cdaeed6c2..00000000000 --- a/src/main/ipc/orcad-ssh-provisioning-handlers.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ipcMain } from 'electron' -import type { OrcadSshProvisioningRequest } from '../../shared/orcad-ssh-provisioning' -import { - createOrcadSshHost, - listPendingOrcadSshProvisioning, - resumeOrcadSshHost -} from '../ssh/orcad-ssh-provisioning' - -export function registerOrcadSshProvisioningHandlers(getUserDataPath: () => string): void { - ipcMain.handle( - 'runtimeEnvironments:createOrcadSshHost', - (_event, args: OrcadSshProvisioningRequest) => createOrcadSshHost(getUserDataPath(), args) - ) - ipcMain.handle('runtimeEnvironments:resumeOrcadSshHost', (_event, args: { requestId: string }) => - resumeOrcadSshHost(getUserDataPath(), args?.requestId) - ) - ipcMain.handle('runtimeEnvironments:listPendingOrcadSshProvisioning', () => - listPendingOrcadSshProvisioning(getUserDataPath()) - ) -} diff --git a/src/main/ipc/parcel-watcher-child-termination.ts b/src/main/ipc/parcel-watcher-child-termination.ts index e7067dc32d6..fc498410438 100644 --- a/src/main/ipc/parcel-watcher-child-termination.ts +++ b/src/main/ipc/parcel-watcher-child-termination.ts @@ -10,13 +10,6 @@ export const WATCHER_PROCESS_HARD_KILL_DELAY_MS = 5_000 export const WATCHER_PROCESS_EXIT_DEADLINE_MS = RUNTIME_FILE_WATCH_EXIT_DEADLINE_MS const physicalExitPromises = new WeakMap>() -const signalledChildren = new WeakSet() - -/** Sends the graceful signal once; a later awaited termination only escalates and waits. */ -export function signalWatcherChild(child: ChildProcess): void { - signalledChildren.add(child) - child.kill() -} export function registerWatcherChildPhysicalExit(child: ChildProcess): () => void { let resolveExit: () => void = () => undefined @@ -93,10 +86,6 @@ export function terminateWatcherChild(child: ChildProcess): Promise { hardKillTimer.unref?.() const exitDeadlineTimer = setTimeout(() => finish(false), WATCHER_PROCESS_EXIT_DEADLINE_MS) exitDeadlineTimer.unref?.() - if (signalledChildren.has(child)) { - return - } - signalledChildren.add(child) try { child.kill() } catch { @@ -105,10 +94,11 @@ export function terminateWatcherChild(child: ChildProcess): Promise { }) } -export function watcherChildPhysicalExit(child: ChildProcess): Promise { - return child.exitCode !== null || child.signalCode !== null - ? Promise.resolve() - : (physicalExitPromises.get(child) ?? +export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure { + const physicalExit = + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : (physicalExitPromises.get(child) ?? new Promise((resolve) => { const finish = (): void => { child.removeListener('exit', finish) @@ -118,14 +108,11 @@ export function watcherChildPhysicalExit(child: ChildProcess): Promise { child.once('exit', finish) child.once('close', finish) })) -} - -export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure { return new WatcherProcessFailure( 'file watcher process did not exit after termination deadline', 'supervisor', 'process_unavailable', - watcherChildPhysicalExit(child) + physicalExit ) } @@ -140,12 +127,10 @@ export async function terminateIdleWatcherChild( pendingUnsubscribes: Map, onFinished: (exited: boolean) => void ): Promise { - // Windows directory handles require physical exit, not merely an accepted signal. try { await requireWatcherChildTermination(child) onFinished(true) } catch (error) { - // Idle children retain capacity but cannot double-watch; the owner may remain reusable. onFinished(false) resolvePendingWatcherUnsubscribes( pendingUnsubscribes, diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index 4633739fe15..229dcdc65e2 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -4,14 +4,6 @@ import { join } from 'node:path' type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } -export function watcherProcessEntryExists(entryPath: string): boolean { - if (existsSync(entryPath)) { - return true - } - console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`) - return false -} - // Why the port and not require('electron'): this module is reachable from plain-Node // fork entries, where the literal text require("electron") fails the build guard even // inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. diff --git a/src/main/ipc/parcel-watcher-owned-children.test.ts b/src/main/ipc/parcel-watcher-owned-children.test.ts deleted file mode 100644 index cbb369cf3e2..00000000000 --- a/src/main/ipc/parcel-watcher-owned-children.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { afterEach, expect, it, vi } from 'vitest' -import { WatcherOwnedChildren } from './parcel-watcher-owned-children' -import { - registerWatcherChildPhysicalExit, - signalWatcherChild, - WATCHER_PROCESS_EXIT_DEADLINE_MS, - WATCHER_PROCESS_HARD_KILL_DELAY_MS -} from './parcel-watcher-child-termination' - -afterEach(() => vi.useRealTimers()) - -function child() { - const exitState: { exitCode: number | null; signalCode: NodeJS.Signals | null } = { - exitCode: null, - signalCode: null - } - const events = Object.assign(new EventEmitter(), exitState, { kill: vi.fn(() => true) }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: termination reads only the exit fields, kill and events stubbed here. - const process = events as unknown as ChildProcess - const physicalExit = registerWatcherChildPhysicalExit(process) - events.on('exit', physicalExit) - events.on('close', physicalExit) - events.on('error', () => {}) - return { process, events, close: () => events.emit('close') } -} - -it('joins disposal and does not confuse disconnect/error with physical exit', async () => { - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const logical = vi.fn() - const disposed = vi.fn() - const first = owner.disposeAndWait(logical) - expect(owner.disposeAndWait(logical)).toBe(first) - const result = first.then(disposed) - c.events.emit('disconnect') - c.events.emit('error', new Error('spawn or IPC failure')) - await Promise.resolve() - expect(disposed).not.toHaveBeenCalled() - expect(logical).toHaveBeenCalledOnce() - c.close() - await result - expect(disposed).toHaveBeenCalledOnce() -}) - -it('includes children already signaled by synchronous or retired-owner disposal', async () => { - const owner = new WatcherOwnedChildren() - const old = child() - const replacement = child() - owner.track(old.process) - old.process.kill() - owner.track(replacement.process) - const disposed = vi.fn() - const result = owner.disposeAndWait(() => {}).then(disposed) - replacement.close() - await Promise.resolve() - expect(disposed).not.toHaveBeenCalled() - old.close() - await result -}) - -it('retains a child after termination deadline failure and supports explicit retry', async () => { - vi.useFakeTimers() - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const result = owner.disposeAndWait(() => {}).catch((error: unknown) => error) - await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) - expect(await result).toMatchObject({ message: 'watcher_owned_children_shutdown_incomplete' }) - const completed = vi.fn() - const retry = owner.disposeAndWait(() => {}).then(completed) - await Promise.resolve() - expect(completed).not.toHaveBeenCalled() - c.close() - await retry -}) - -it('waits for other children even when logical disposal and one kill fail', async () => { - const owner = new WatcherOwnedChildren() - const failed = child() - const pending = child() - owner.track(failed.process) - owner.track(pending.process) - failed.events.kill.mockImplementationOnce(() => { - throw new Error('kill failed') - }) - const logicalFailure = new Error('logical cleanup failed') - const finished = vi.fn() - const result = owner - .disposeAndWait(() => { - throw logicalFailure - }) - .catch((error: unknown) => { - finished() - return error - }) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - pending.close() - expect(await result).toMatchObject({ errors: [logicalFailure, expect.any(Error)] }) - const retry = owner.disposeAndWait(() => {}) - failed.close() - await retry -}) - -it('skips a second graceful signal after the supervisor sent one but still escalates', async () => { - vi.useFakeTimers() - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const disposal = owner.disposeAndWait(() => signalWatcherChild(c.process)) - expect(c.events.kill).toHaveBeenCalledTimes(1) - expect(c.events.kill).toHaveBeenCalledWith() - await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_HARD_KILL_DELAY_MS) - expect(c.events.kill).toHaveBeenLastCalledWith('SIGKILL') - c.events.emit('exit', null, 'SIGKILL') - await expect(disposal).resolves.toBeUndefined() -}) diff --git a/src/main/ipc/parcel-watcher-owned-children.ts b/src/main/ipc/parcel-watcher-owned-children.ts deleted file mode 100644 index 7ac044ac906..00000000000 --- a/src/main/ipc/parcel-watcher-owned-children.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' -import { - watcherChildPhysicalExit, - requireWatcherChildTermination -} from './parcel-watcher-child-termination' - -export class WatcherOwnedChildren { - private readonly children = new Set() - private disposal: Promise | null = null - - track(child: ChildProcessHandle): ChildProcessHandle { - this.children.add(child) - // Reuse launch-owned physical-exit evidence, including close without exit after spawn failure. - void watcherChildPhysicalExit(child).then(() => { - this.children.delete(child) - }) - return child - } - - disposeAndWait(disposeLogicalOwner: () => void): Promise { - if (this.disposal) { - return this.disposal - } - const failures: unknown[] = [] - try { - disposeLogicalOwner() - } catch (error) { - failures.push(error) - } - const cleanup = Promise.allSettled([...this.children].map(requireWatcherChildTermination)) - .then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - failures.push(result.reason) - } - } - if (failures.length > 0) { - throw new AggregateError(failures, 'watcher_owned_children_shutdown_incomplete') - } - }) - .finally(() => { - this.disposal = null - }) - this.disposal = cleanup - return cleanup - } -} diff --git a/src/main/ipc/parcel-watcher-process-dispose.test.ts b/src/main/ipc/parcel-watcher-process-dispose.test.ts deleted file mode 100644 index fd022c16622..00000000000 --- a/src/main/ipc/parcel-watcher-process-dispose.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const { supervisorDispose, poolDispose } = vi.hoisted(() => ({ - supervisorDispose: vi.fn<() => Promise>(), - poolDispose: vi.fn<() => Promise>() -})) - -vi.mock('./parcel-watcher-process-supervisor', () => ({ - WatcherProcessSupervisor: class { - disposeAndWait = supervisorDispose - } -})) -vi.mock('./runtime-watcher-process-pool', () => ({ - RuntimeWatcherProcessPool: class { - disposeAndWait = poolDispose - } -})) - -import { disposeWatcherProcessAndWait } from './parcel-watcher-process' - -describe('disposeWatcherProcessAndWait', () => { - it('waits for both watcher hosts, and reports a failed one only after the other settles', async () => { - let finishPool!: () => void - supervisorDispose.mockRejectedValueOnce(new Error('child still running')) - poolDispose.mockReturnValueOnce( - new Promise((resolve) => { - finishPool = resolve - }) - ) - const settled = vi.fn() - const disposal = disposeWatcherProcessAndWait().catch((error: unknown) => { - settled() - throw error - }) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finishPool() - await expect(disposal).rejects.toMatchObject({ message: 'watcher_process_shutdown_incomplete' }) - }) - - it('resolves once every owned child has exited', async () => { - supervisorDispose.mockResolvedValueOnce() - poolDispose.mockResolvedValueOnce() - await expect(disposeWatcherProcessAndWait()).resolves.toBeUndefined() - expect(supervisorDispose).toHaveBeenCalled() - expect(poolDispose).toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts index 513775acb29..6ebdb5385dd 100644 --- a/src/main/ipc/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -86,13 +86,8 @@ describe('parcel watcher process canary', () => { await vi.advanceTimersByTimeAsync(0) expect(sendMock).toHaveBeenCalledWith( - expect.objectContaining({ op: 'subscribe-failed', id: 7 }), - expect.any(Function) + expect.objectContaining({ op: 'subscribe-failed', id: 7 }) ) - const failedSend = sendMock.mock.calls.find(([message]) => message.op === 'subscribe-failed') - expect(() => - failedSend![1](Object.assign(new Error('host disconnected'), { code: 'EPIPE' })) - ).not.toThrow() expect(watchMock).not.toHaveBeenCalledWith('/repo/.git', expect.anything(), expect.anything()) }) @@ -318,13 +313,10 @@ describe('parcel watcher process canary', () => { finishActiveCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 }) expect(subscribeMock).toHaveBeenCalledTimes(2) - expect(sendMock).not.toHaveBeenCalledWith( - { op: 'subscribe-started', id: 2 }, - expect.any(Function) - ) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 }, expect.any(Function)) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribe-started', id: 2 }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 }) }) it('unsubscribes a late cancel after the crawl already finished', async () => { @@ -340,16 +332,13 @@ describe('parcel watcher process canary', () => { process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }) process.emit('message', { op: 'cancel-subscribe', id: 1 }) await vi.advanceTimersByTimeAsync(0) expect(unsubscribe).toHaveBeenCalledTimes(1) - expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }, expect.any(Function)) - expect(sendMock).not.toHaveBeenCalledWith( - { op: 'cancel-requires-restart', id: 1 }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) }) it('reports native unsubscribe rejection without acknowledging handle release', async () => { @@ -367,15 +356,12 @@ describe('parcel watcher process canary', () => { process.emit('message', { op: 'unsubscribe', id: 1 }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith( - { - op: 'unsubscribe-failed', - id: 1, - message: 'native handle still active' - }, - expect.any(Function) - ) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ + op: 'unsubscribe-failed', + id: 1, + message: 'native handle still active' + }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }) }) it('asks the host to restart when an active crawl is cancelled', async () => { @@ -394,13 +380,10 @@ describe('parcel watcher process canary', () => { await vi.advanceTimersByTimeAsync(0) process.emit('message', { op: 'cancel-subscribe', id: 1 }) - expect(sendMock).toHaveBeenCalledWith( - { op: 'cancel-requires-restart', id: 1 }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) finishCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function)) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 }) }) it('still restarts after consecutive missed events once every subscription is live', async () => { @@ -498,14 +481,11 @@ describe('parcel watcher process canary', () => { callback?.(null, [{ type: 'update', path: '/repo/after-overflow.txt' }]) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith( - { - op: 'watch-error', - id: 1, - message: 'Events were dropped by the FSEvents client. File system must be re-scanned.' - }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ + op: 'watch-error', + id: 1, + message: 'Events were dropped by the FSEvents client. File system must be re-scanned.' + }) expect(sendMock).toHaveBeenCalledWith( { op: 'events', diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index d2328821ade..0de05010f47 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -118,7 +118,7 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom function main(): void { const send = (message: WatcherToHostMessage): void => { try { - process.send?.(message, () => undefined) + process.send?.(message) } catch { // Host is gone; the disconnect handler below exits this process. } diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts index 926ff036466..3f0a534c198 100644 --- a/src/main/ipc/parcel-watcher-process-supervisor.ts +++ b/src/main/ipc/parcel-watcher-process-supervisor.ts @@ -1,7 +1,8 @@ import type { ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' import { restartCancelledWatcherChild } from './parcel-watcher-cancellation-restart' import { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' -import { getWatcherProcessEntryPath, watcherProcessEntryExists } from './parcel-watcher-entry-path' +import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path' import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' import * as termination from './parcel-watcher-child-termination' import { launchWatcherChild } from './parcel-watcher-child-launch' @@ -38,7 +39,6 @@ import { } from './parcel-watcher-supervisor-subscribe' import { disposeWatcherSupervisor } from './parcel-watcher-supervisor-disposal' import { handleWatcherSupervisorMessage } from './parcel-watcher-supervisor-message' -import { WatcherOwnedChildren } from './parcel-watcher-owned-children' export class WatcherProcessSupervisor { private child: ChildProcess | null = null @@ -52,7 +52,6 @@ export class WatcherProcessSupervisor { private readonly pendingUnsubscribes = new Map() private readonly cancelledSubscribes = new WatcherCancellationTracker() private readonly capacityWait = new WatcherSupervisorCapacityWait() - private ownedChildren = new WatcherOwnedChildren() constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {} @@ -108,7 +107,6 @@ export class WatcherProcessSupervisor { resetForTest(): void { this.dispose() - this.ownedChildren = new WatcherOwnedChildren() this.shutdownRequested = false this.terminatingChild = null this.terminationQueue.resetForTest() @@ -116,8 +114,6 @@ export class WatcherProcessSupervisor { resetWatcherChildRegistryForTest() } - disposeAndWait = (): Promise => this.ownedChildren.disposeAndWait(() => this.dispose()) - private ensureWatcherProcess( entryPath = this.options.entryPath ?? getWatcherProcessEntryPath() ): ChildProcess | null { @@ -130,7 +126,8 @@ export class WatcherProcessSupervisor { if (this.crashFuse.isOpen()) { return null } - if (!watcherProcessEntryExists(entryPath)) { + if (!existsSync(entryPath)) { + console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`) return null } const launched = launchWatcherChild( @@ -148,7 +145,7 @@ export class WatcherProcessSupervisor { return null } this.canaryDir = launched.canaryDir - this.child = this.ownedChildren.track(launched.child) + this.child = launched.child return launched.child } @@ -254,9 +251,14 @@ export class WatcherProcessSupervisor { } this.child = null this.terminatingChild = proc + // Why: destructive Windows cleanup must await exit to release directory handles. this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) return this.terminationQueue.track( termination.terminateIdleWatcherChild(proc, this.pendingUnsubscribes, () => { + // Why: an idle child owns zero records, so a missed exit deadline has no + // double-watch hazard; the child keeps its capacity reservation until + // physical exit, and poisoning this supervisor would permanently end + // local watching — the shared singleton has no retire-and-replace path. this.terminatingChild = null }) ) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 74e2c3ffa69..223fed564f8 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -62,20 +62,6 @@ export function disposeWatcherProcess(): void { } } -/** Dispose both watcher hosts and wait for every child they own to exit. */ -export async function disposeWatcherProcessAndWait(): Promise { - const results = await Promise.allSettled([ - sharedWatcherProcessSupervisor.disposeAndWait(), - runtimeWatcherProcessPool.disposeAndWait() - ]) - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (failures.length > 0) { - throw new AggregateError(failures, 'watcher_process_shutdown_incomplete') - } -} - export function resetWatcherProcessForTest(): void { sharedWatcherProcessSupervisor.resetForTest() } diff --git a/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts b/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts deleted file mode 100644 index d3aaa15770c..00000000000 --- a/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { acknowledgeWatcherSubscribe, FakeWatcherChild } from './parcel-watcher-process-test-child' -import { resetWatcherChildRegistryForTest } from './parcel-watcher-child-registry' - -const { forkMock } = vi.hoisted(() => ({ forkMock: vi.fn() })) -vi.mock('node:child_process', () => ({ fork: forkMock })) -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), - mkdtempSync: vi.fn(() => '/tmp/orca-watcher-disposal-test'), - rmSync: vi.fn() -})) -import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' - -const children: FakeWatcherChild[] = [] -beforeEach(() => { - resetWatcherChildRegistryForTest() - forkMock.mockImplementation(() => { - const child = new FakeWatcherChild() - children.push(child) - return child - }) -}) -afterEach(() => { - for (const child of children.splice(0)) { - child.emit('close') - } - vi.clearAllMocks() - resetWatcherChildRegistryForTest() -}) - -async function subscribe(supervisor: WatcherProcessSupervisor): Promise { - const pending = supervisor.subscribe('/repo', vi.fn(), {}) - const child = children.at(-1)! - acknowledgeWatcherSubscribe(child) - await pending - return child -} - -it('awaited supervisor disposal retains a child after prior synchronous disposal', async () => { - const supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false }) - const child = await subscribe(supervisor) - supervisor.dispose() - const finished = vi.fn() - const shutdown = supervisor.disposeAndWait().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(supervisor.subscribe('/another', vi.fn(), {})).rejects.toThrow() - expect(forkMock).toHaveBeenCalledOnce() - child.emit('close') - await shutdown - expect(finished).toHaveBeenCalledOnce() -}) - -it('test reset isolates old physical-exit callbacks from the new lifetime owner', async () => { - const supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false }) - const old = await subscribe(supervisor) - const oldShutdown = supervisor.disposeAndWait() - supervisor.resetForTest() - const current = await subscribe(supervisor) - const finished = vi.fn() - const shutdown = supervisor.disposeAndWait().then(finished) - old.emit('close') - await oldShutdown - expect(finished).not.toHaveBeenCalled() - current.emit('close') - await shutdown -}) diff --git a/src/main/ipc/parcel-watcher-supervisor-disposal.ts b/src/main/ipc/parcel-watcher-supervisor-disposal.ts index 07bc73ef3ee..42dc918e05b 100644 --- a/src/main/ipc/parcel-watcher-supervisor-disposal.ts +++ b/src/main/ipc/parcel-watcher-supervisor-disposal.ts @@ -9,7 +9,6 @@ import { resetPendingSubscribeAttempt, takePendingSubscribe } from './parcel-watcher-pending-subscribe' -import { signalWatcherChild } from './parcel-watcher-child-termination' import { watcherHostFailure } from './parcel-watcher-process-failure' import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' @@ -28,8 +27,6 @@ export function disposeWatcherSupervisor( resolvePendingWatcherUnsubscribes(pendingUnsubscribes) cancelledSubscribes.completeAll() records.clear() - if (child) { - signalWatcherChild(child) - } + child?.kill() return removeWatcherCanaryDirectory(canaryDir) } diff --git a/src/main/ipc/runtime-environment-capability-evidence.ts b/src/main/ipc/runtime-environment-capability-evidence.ts index 7dd47712fb2..197ec71f4f8 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.ts @@ -64,10 +64,6 @@ export function advanceRuntimeEnvironmentCapabilityIncarnation(environmentId: st state.accepted = null } -export function getRuntimeEnvironmentCapabilityIncarnation(environmentId: string): number { - return stateFor(environmentId).epoch -} - export function applyRuntimeEnvironmentCapabilityVerdict(args: { evidence: RuntimeEnvironmentCapabilityEvidence verdict: RuntimeEnvironmentCapabilityVerdict diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 6513c0756b8..84d2754d556 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -14,8 +14,10 @@ import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call- import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import type { Store } from '../persistence' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' +import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime' +import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement' import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' +import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { closeRemoteRuntimeRequestConnection, getRuntimeEnvironmentStatusOwner, @@ -104,8 +106,22 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ throw new Error('Choose another Active Server in Advanced before removing this server.') } const removed = removeEnvironment(getUserDataPath(), args.selector) - void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport) + clearRuntimeEnvironmentCapabilityEvidence(removed.id) + clearRuntimeEnvironmentManualDisconnect(removed.id) + const retiring = Promise.resolve(invalidateTransport(removed.id)) closeLegacySelectorTransport(args.selector, removed.id) + // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes + // too -- but only once the client host releases its partitions, or every one refuses as live. + void retireBrowserRoutePartitionStorageForEnvironment({ + environmentId: removed.id, + whenClientHostClosed: retiring, + clearStorage: clearBrowserRoutePartitionStorageForEnvironment, + onError: (error) => { + console.warn('[runtime-environments] browser partition storage clear failed:', error) + } + }).catch((error) => { + console.warn('[runtime-environments] browser partition storage clear failed:', error) + }) return { removed: redactRuntimeEnvironment(removed) } } ) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 3af5e61a64c..23b40fe5183 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -12,17 +12,5 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', - 'runtimeEnvironments:unsubscribe', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning' + 'runtimeEnvironments:unsubscribe' ] as const diff --git a/src/main/ipc/runtime-environment-managed-tunnel.ts b/src/main/ipc/runtime-environment-managed-tunnel.ts deleted file mode 100644 index d496dbd8250..00000000000 --- a/src/main/ipc/runtime-environment-managed-tunnel.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel' - -export async function resolveManagedRuntimeEnvironment( - userDataPath: string, - selector: string -): Promise> { - const environment = resolveEnvironment(userDataPath, selector) - await ensureOrcadManagedTunnel(userDataPath, environment.id) - return resolveEnvironment(userDataPath, environment.id) -} diff --git a/src/main/ipc/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environment-removal-cleanup.ts deleted file mode 100644 index c4141c0e00e..00000000000 --- a/src/main/ipc/runtime-environment-removal-cleanup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime' -import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement' -import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' -import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect' - -/** Retires a removed server's client-side state; resolves once its transport is invalidated. */ -export function retireRemovedRuntimeEnvironment( - environmentId: string, - invalidateTransport: (environmentId: string) => Promise | void -): Promise { - clearRuntimeEnvironmentCapabilityEvidence(environmentId) - clearRuntimeEnvironmentManualDisconnect(environmentId) - const retiring = Promise.resolve(invalidateTransport(environmentId)) - // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes - // too -- but only once the client host releases its partitions, or every one refuses as live. - void retireBrowserRoutePartitionStorageForEnvironment({ - environmentId, - whenClientHostClosed: retiring, - clearStorage: clearBrowserRoutePartitionStorageForEnvironment, - onError: (error) => { - console.warn('[runtime-environments] browser partition storage clear failed:', error) - } - }).catch((error) => { - console.warn('[runtime-environments] browser partition storage clear failed:', error) - }) - return retiring -} diff --git a/src/main/ipc/runtime-environment-revision-guard.ts b/src/main/ipc/runtime-environment-revision-guard.ts index 03d6169675d..af70d556356 100644 --- a/src/main/ipc/runtime-environment-revision-guard.ts +++ b/src/main/ipc/runtime-environment-revision-guard.ts @@ -15,14 +15,6 @@ export function runtimeEnvironmentRevisionFailure( if (!pairingChanged && !runtimeChanged) { return null } - return runtimeEnvironmentChangedFailure(environment, method, pairingChanged) -} - -export function runtimeEnvironmentChangedFailure( - environment: Pick, - method: string, - pairingChanged = false -): RuntimeRpcResponse { return { id: method, ok: false, diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index 88586577905..f88741ca9a3 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -8,7 +8,6 @@ import { } from '../../shared/runtime-environments' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' -import type { RuntimeStatus } from '../../shared/runtime-types' import { RUNTIME_HOST_STATUS_CHANNEL, type RuntimeHostStatusResponse @@ -16,12 +15,9 @@ import { import { applyRuntimeEnvironmentCapabilityVerdict, getAcceptedRuntimeEnvironmentCapabilityOutcome, - getRuntimeEnvironmentCapabilityIncarnation, captureRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' -import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' export function createRuntimeEnvironmentStatusOwner( userDataPath: string, @@ -38,23 +34,13 @@ export function createRuntimeEnvironmentStatusOwner( return new RuntimeHostStatusOwner({ environmentId: environment.id, pairingRevision: environment.pairingRevision ?? environment.createdAt, - request: async (signal) => { - const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id) - const isCurrent = (): boolean => - getRuntimeEnvironmentCapabilityIncarnation(environment.id) === incarnation - if (environment.connectionDependency === 'ssh-tunnel') { - await resolveManagedRuntimeEnvironment(userDataPath, environment.id) - if (!isCurrent()) { - return runtimeEnvironmentChangedFailure(environment, 'status.get') - } - signal.throwIfAborted() - } + request: (signal) => { evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - const response = await (transport.isReady() && - getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === - 'supported' + return transport.isReady() && + getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === + 'supported' ? transport.request(signal) - : sendRemoteRuntimeRequest( + : sendRemoteRuntimeRequest( pairing, 'status.get', undefined, @@ -62,8 +48,7 @@ export function createRuntimeEnvironmentStatusOwner( undefined, signal, ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - )) - return isCurrent() ? response : runtimeEnvironmentChangedFailure(environment, 'status.get') + ) }, verified: (response, active) => { const capable = diff --git a/src/main/ipc/runtime-environment-status-probe.ts b/src/main/ipc/runtime-environment-status-probe.ts deleted file mode 100644 index 54a93822a9e..00000000000 --- a/src/main/ipc/runtime-environment-status-probe.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' -import type { RuntimeStatus } from '../../shared/runtime-types' -import { getRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' -import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' -import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' -import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' -import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' - -export async function getRuntimeEnvironmentStatus( - userDataPath: string, - selector: string, - timeoutMs?: number, - options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } -): Promise> { - const environment = resolveEnvironment(userDataPath, selector) - if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - return { - id: 'status.get', - ok: false, - error: { - code: 'runtime_manually_disconnected', - message: 'Runtime environment is manually disconnected.' - } - } - } - const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id) - const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ - timeoutMs, - ...options - }) - // A retired request must not publish old status or borrow its replacement's diagnostics. - if (getRuntimeEnvironmentCapabilityIncarnation(environment.id) !== incarnation) { - return runtimeEnvironmentChangedFailure(environment, 'status.get') - } - return attachRemoteControlDiagnostics( - withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), - environment.id - ) -} diff --git a/src/main/ipc/runtime-environment-status-retirement.test.ts b/src/main/ipc/runtime-environment-status-retirement.test.ts deleted file mode 100644 index 37ebf690a60..00000000000 --- a/src/main/ipc/runtime-environment-status-retirement.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { - addEnvironmentFromPairingCode, - resolveEnvironment -} from '../../shared/runtime-environment-store' -import { pairingCode } from './runtime-environments-ipc-test-harness' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' -import { - advanceRuntimeEnvironmentCapabilityIncarnation, - resetRuntimeEnvironmentCapabilityEvidence -} from './runtime-environment-capability-evidence' - -const mocks = vi.hoisted(() => ({ - request: vi.fn(), - ensure: vi.fn(), - reconnect: vi.fn(), - diagnostics: vi.fn() -})) -vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: mocks.request })) -vi.mock('./runtime-environment-request-connections', async () => { - const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') - return withRuntimeStatusOwners({ - ensureRemoteRuntimeSharedControlConnection: mocks.ensure, - reconnectRemoteRuntimeSharedControlConnection: mocks.reconnect, - getRemoteRuntimeSharedControlDiagnostics: mocks.diagnostics, - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: vi.fn() - }) -}) -vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) - -import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' -import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' - -let directory: string -let envId: string -beforeEach(() => { - vi.clearAllMocks() - resetRuntimeEnvironmentCapabilityEvidence() - directory = mkdtempSync(join(tmpdir(), 'orca-status-retirement-')) - envId = addEnvironmentFromPairingCode(directory, { - name: 'Host', - now: 1, - pairingCode: pairingCode(), - connectionDependency: 'ssh-tunnel' - }).id - mocks.diagnostics.mockReturnValue(null) -}) -afterEach(() => { - resetRuntimeEnvironmentStatusOwners() - rmSync(directory, { recursive: true, force: true }) -}) - -function response() { - return { - id: 'status', - ok: true as const, - result: { runtimeId: 'old-host', capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }, - _meta: { runtimeId: 'old-host' } - } -} - -it.each(['success', 'failure', 'throw'] as const)( - 'fences a retired status probe settling with %s without publishing status or diagnostics', - async (settlement) => { - const pending = Promise.withResolvers() - mocks.request.mockReturnValue(pending.promise) - const probe = getRuntimeEnvironmentStatus(directory, envId) - await vi.waitFor(() => expect(mocks.request).toHaveBeenCalledOnce()) - mocks.diagnostics.mockClear() - advanceRuntimeEnvironmentCapabilityIncarnation(envId) - if (settlement === 'throw') { - pending.reject(new Error('old endpoint offline')) - } else { - pending.resolve( - settlement === 'success' - ? response() - : { - id: 'status', - ok: false, - error: { code: 'offline', message: 'old endpoint' }, - _meta: { runtimeId: 'old-host' } - } - ) - } - await expect(probe).resolves.toMatchObject({ - ok: false, - error: { code: 'runtime_environment_changed' } - }) - expect(resolveEnvironment(directory, envId).runtimeId).toBeNull() - expect(mocks.ensure).not.toHaveBeenCalled() - expect(mocks.reconnect).not.toHaveBeenCalled() - expect(mocks.diagnostics).not.toHaveBeenCalled() - } -) - -it('does not fence another environment or a fresh probe after retirement', async () => { - mocks.request.mockImplementation(async () => { - advanceRuntimeEnvironmentCapabilityIncarnation('other') - return response() - }) - await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({ ok: true }) - advanceRuntimeEnvironmentCapabilityIncarnation(envId) - await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({ ok: true }) - expect(resolveEnvironment(directory, envId).runtimeId).toBe('old-host') - expect(mocks.ensure).toHaveBeenCalledTimes(2) -}) diff --git a/src/main/ipc/runtime-environment-subscription-identity.test.ts b/src/main/ipc/runtime-environment-subscription-identity.test.ts deleted file mode 100644 index ce1a72d8945..00000000000 --- a/src/main/ipc/runtime-environment-subscription-identity.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' -import type { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -const mocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn(), subscribe: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle, on: mocks.on } })) -vi.mock('../../shared/runtime-environment-store', () => ({ - resolveEnvironment: () => ({ id: 'env', pairingRevision: 1, createdAt: 1 }) -})) -vi.mock('./runtime-environment-transport-routing', () => ({ - subscribeRuntimeEnvironment: mocks.subscribe -})) -import { - closeSubscriptionsForEnvironment, - registerRuntimeEnvironmentSubscriptions -} from './runtime-environment-subscriptions' - -type Callbacks = Parameters[5] -const sender = { - id: 1, - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - removeListener: vi.fn() -} -const args = { selector: 'env', method: 'terminal.multiplex', subscriptionId: 'reused-id' } -function handler(channel: string) { - return mocks.handle.mock.calls.find(([name]) => name === channel)![1] -} -const open = () => handler('runtimeEnvironments:subscribe')({ sender }, args) -const unsubscribe = () => - handler('runtimeEnvironments:unsubscribe')({ sender }, { subscriptionId: args.subscriptionId }) -const connection = (): RemoteRuntimeSubscription => ({ - requestId: 'request', - sendBinary: vi.fn(() => true), - close: vi.fn() -}) - -beforeEach(() => { - vi.clearAllMocks() - mocks.subscribe.mockReset() - registerRuntimeEnvironmentSubscriptions(() => '/profile') -}) -afterEach(() => closeSubscriptionsForEnvironment('env')) - -it('reserves an ID while setup is pending so a second open cannot take its ownership', async () => { - const gate = Promise.withResolvers() - mocks.subscribe.mockReturnValue(gate.promise) - const first = open() - await expect(open()).rejects.toThrow('already exists') - expect(mocks.subscribe).toHaveBeenCalledOnce() - gate.resolve(connection()) - await expect(first).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) - -it('releases the reservation after failed setup so an explicit retry can open', async () => { - mocks.subscribe.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(connection()) - await expect(open()).rejects.toThrow('offline') - await expect(open()).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) - -it('does not let callbacks from an old subscription publish to or remove its replacement', async () => { - const callbacks: Callbacks[] = [] - const currentChecks: (() => boolean)[] = [] - const connections: RemoteRuntimeSubscription[] = [] - mocks.subscribe.mockImplementation( - async (...parameters: Parameters) => { - callbacks.push(parameters[5]) - currentChecks.push(parameters[6]!) - const value = connection() - connections.push(value) - return value - } - ) - await open() - expect(unsubscribe()).toEqual({ unsubscribed: true }) - await open() - sender.send.mockClear() - expect(currentChecks[0]()).toBe(false) - expect(currentChecks[1]()).toBe(true) - const payload = { type: 'binary' as const, bytes: new Uint8Array([1]) } - callbacks[0].onEvent(payload) - callbacks[0].onEvent({ type: 'close' }) - callbacks[0].onClose() - expect(sender.send).not.toHaveBeenCalled() - callbacks[1].onEvent(payload) - expect(sender.send).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', { - subscriptionId: 'reused-id', - ...payload - }) - expect(unsubscribe()).toEqual({ unsubscribed: true }) - expect(connections[1].close).toHaveBeenCalledOnce() -}) - -it('does not retain a subscription that closes before setup resolves', async () => { - const value = connection() - mocks.subscribe.mockImplementationOnce( - async (...parameters: Parameters) => { - parameters[5].onEvent({ type: 'close' }) - parameters[5].onClose() - return value - } - ) - await expect(open()).rejects.toThrow('closed during setup') - expect(value.close).toHaveBeenCalledOnce() - mocks.subscribe.mockResolvedValueOnce(connection()) - await expect(open()).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) diff --git a/src/main/ipc/runtime-environment-subscriptions.ts b/src/main/ipc/runtime-environment-subscriptions.ts deleted file mode 100644 index f1fdd9aaa3a..00000000000 --- a/src/main/ipc/runtime-environment-subscriptions.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { ipcMain } from 'electron' -import { randomUUID } from 'node:crypto' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' -import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' -import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { - token: symbol - environmentId: string - ownerWebContentsId: number - removeDestroyedListener: () => void - notifyClosed: () => void -} -const remoteRuntimeSubscriptions = new Map() -// Why: an id is claimed while its socket opens, so a duplicate or a late callback cannot cross over. -const pendingSubscriptions = new Map() - -export function closeSubscriptionsForEnvironment(environmentId: string): void { - // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. - for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { - if (subscription.environmentId !== environmentId) { - continue - } - remoteRuntimeSubscriptions.delete(subscriptionId) - // Why: one failing teardown must not abandon this environment's other - // sockets -- that strands exactly the dead handles this sweep exists to - // retire. Guard the two steps independently so neither can skip the other, - // and so the isolation stays structural rather than resting on a claim that - // nothing inside notifyClosed will ever throw. - try { - subscription.close() - } catch (error) { - console.warn('[runtime-environments] subscription close failed during retirement:', error) - } - try { - // Why: a shared-control logical close never calls back, so notify directly. - subscription.notifyClosed() - } catch (error) { - console.warn('[runtime-environments] subscription close notice failed:', error) - } - } -} -export function registerRuntimeEnvironmentSubscriptions(getUserDataPath: () => string): void { - ipcMain.handle( - 'runtimeEnvironments:subscribe', - async ( - event, - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - subscriptionId?: string - expectedEnvironmentPairingRevision?: number - expectedEnvironmentRuntimeId?: string - } - ): Promise<{ subscriptionId: string; requestId: string }> => { - const subscriptionId = - typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 - ? args.subscriptionId - : randomUUID() - if ( - remoteRuntimeSubscriptions.has(subscriptionId) || - pendingSubscriptions.has(subscriptionId) - ) { - throw new Error('Runtime environment subscription id already exists') - } - const environment = resolveEnvironment(getUserDataPath(), args.selector) - if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - throw new Error('runtime_manually_disconnected') - } - const pairingRevision = environment.pairingRevision ?? environment.createdAt - if ( - args.expectedEnvironmentPairingRevision !== undefined && - pairingRevision !== args.expectedEnvironmentPairingRevision - ) { - throw new Error('Runtime environment pairing changed; refresh and try again') - } - if ( - args.expectedEnvironmentRuntimeId !== undefined && - environment.runtimeId !== args.expectedEnvironmentRuntimeId - ) { - throw new Error('Runtime environment identity changed; refresh and try again') - } - const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) - const token = Symbol(subscriptionId) - let closed = false - const ownsId = (): boolean => - pendingSubscriptions.get(subscriptionId) === token || - remoteRuntimeSubscriptions.get(subscriptionId)?.token === token - const releasePending = (): void => { - if (pendingSubscriptions.get(subscriptionId) === token) { - pendingSubscriptions.delete(subscriptionId) - } - } - const transportIsCurrent = (): boolean => - getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration - const sender = event.sender - const ownerWebContentsId = sender.id - let senderDestroyed = sender.isDestroyed() - let subscription: RemoteRuntimeSubscription | null = null - let destroyedListenerAttached = false - const removeDestroyedListener = (): void => { - if (!destroyedListenerAttached) { - return - } - destroyedListenerAttached = false - sender.removeListener('destroyed', closeSubscription) - } - const closeSubscription = (): void => { - senderDestroyed = true - closed = true - releasePending() - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - if (retained?.token === token) { - remoteRuntimeSubscriptions.delete(subscriptionId) - retained.close() - return - } - removeDestroyedListener() - subscription?.close() - } - // Why: the renderer treats close as terminal and drops its handle, so send it once. - // Latch before sending so a re-entrant call cannot duplicate it, and never - // throw: a dying renderer must not abort its siblings' retirement. - let closeNotified = false - const notifyClosed = (): void => { - closed = true - if (closeNotified || sender.isDestroyed()) { - return - } - closeNotified = true - try { - sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, type: 'close' }) - } catch { - // The renderer is gone; there is no one left to tell. - } - } - pendingSubscriptions.set(subscriptionId, token) - try { - sender.once('destroyed', closeSubscription) - destroyedListenerAttached = true - subscription = await subscribeRuntimeEnvironment( - getUserDataPath(), - environment.id, - args.method, - args.params, - args.timeoutMs, - { - onEvent: (payload) => { - if (payload.type === 'close') { - // Why: retirement advances the generation before closing, so gating - // close on it stranded the renderer with a dead subscription. - if (ownsId()) { - notifyClosed() - } - return - } - if (!closed && ownsId() && transportIsCurrent() && !sender.isDestroyed()) { - sender.send('runtimeEnvironments:subscriptionEvent', { - subscriptionId, - ...payload - }) - } - }, - onClose: () => { - closed = true - releasePending() - removeDestroyedListener() - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - if (retained?.token === token) { - remoteRuntimeSubscriptions.delete(subscriptionId) - } - } - }, - () => !closed && ownsId() && transportIsCurrent() - ) - } catch (error) { - closed = true - releasePending() - removeDestroyedListener() - throw error - } - releasePending() - let pairingIsCurrent = false - try { - const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) - pairingIsCurrent = - (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision - } catch { - pairingIsCurrent = false - } - if (!transportIsCurrent() || !pairingIsCurrent) { - removeDestroyedListener() - subscription.close() - throw new Error('Runtime environment pairing changed; refresh and try again') - } - if (senderDestroyed || sender.isDestroyed()) { - removeDestroyedListener() - subscription.close() - return { subscriptionId, requestId: subscription.requestId } - } - if (closed) { - removeDestroyedListener() - subscription.close() - throw new Error('Runtime environment subscription closed during setup') - } - remoteRuntimeSubscriptions.set(subscriptionId, { - token, - requestId: subscription.requestId, - environmentId: environment.id, - ownerWebContentsId, - removeDestroyedListener, - notifyClosed, - sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, - close: () => { - closed = true - removeDestroyedListener() - subscription?.close() - } - }) - return { subscriptionId, requestId: subscription.requestId } - } - ) - ipcMain.handle( - 'runtimeEnvironments:unsubscribe', - (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { - return { unsubscribed: false } - } - remoteRuntimeSubscriptions.delete(args.subscriptionId) - subscription.close() - return { unsubscribed: true } - } - ) - ipcMain.on( - 'runtimeEnvironments:subscriptionBinary', - (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { - if (typeof args.subscriptionId !== 'string') { - return - } - const bytes = toBinaryPayload(args.bytes) - if (!bytes) { - return - } - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (subscription?.ownerWebContentsId === event.sender.id) { - subscription.sendBinary(bytes) - } - } - ) -} - -function toBinaryPayload(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) { - return value - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value) - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) - } - return null -} diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index f1a846d988e..ae68b3d35f8 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -1,4 +1,3 @@ -import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { PairingOffer } from '../../shared/pairing' import type { @@ -19,10 +18,7 @@ import { isRuntimeEnvironmentCapabilityOutcomeCurrent, type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' -import { - runtimeEnvironmentChangedFailure, - runtimeEnvironmentRevisionFailure -} from './runtime-environment-revision-guard' +import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { supportsSharedControl } from './runtime-environment-shared-control-support' import { sendRemoteRuntimeRequestAbortable, @@ -241,6 +237,21 @@ export async function routeRuntimeEnvironmentSubscriptionBySupport { + return { + id: method, + ok: false, + error: { + code: 'runtime_environment_changed', + message: 'Runtime environment pairing changed; refresh and try again' + }, + _meta: { runtimeId: environment.runtimeId } + } +} + function subscriptionCallbacks( args: Pick< Parameters[0], @@ -272,13 +283,3 @@ function subscriptionCallbacks( } } } - -export function shouldUseSharedControlEnvelope( - method: string, - params: unknown, - envelope: RuntimeOrchestrationEnvelope | undefined -): RuntimeOrchestrationEnvelope | undefined { - return envelope && method.startsWith('orchestration.') && !isOrchestrationMutation(method, params) - ? envelope - : undefined -} diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index f67ca33b467..fd60ef33fcd 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,28 +1,32 @@ -import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' -import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' +import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../shared/runtime-types' import { subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' +import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { sendRemoteRuntimeConnectionRequestAbortable, sendRemoteRuntimeRequestAbortable } from './runtime-environment-abortable-requests' +import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' + +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' +import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' import { resetSharedControlSupport } from './runtime-environment-shared-control-support' import { executeSupportRoutedCall, - shouldUseSharedControlEnvelope, shouldRouteCallBySupport, shouldRouteSubscriptionBySupport, subscribeSupportRoutedRuntimeEnvironment @@ -32,7 +36,32 @@ const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 export { resetSharedControlSupport } -export { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' +export async function getRuntimeEnvironmentStatus( + userDataPath: string, + selector: string, + timeoutMs?: number, + options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } +): Promise> { + const environment = resolveEnvironment(userDataPath, selector) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + return { + id: 'status.get', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: 'Runtime environment is manually disconnected.' + } + } + } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs, + ...options + }) + return attachRemoteControlDiagnostics( + withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), + environment.id + ) +} export async function callRuntimeEnvironment( userDataPath: string, @@ -65,10 +94,7 @@ export async function callRuntimeEnvironment( environment.id, method, async () => { - const currentEnvironment = await resolveManagedRuntimeEnvironment( - userDataPath, - environment.id - ) + const currentEnvironment = resolveEnvironment(userDataPath, environment.id) const revisionFailure = runtimeEnvironmentRevisionFailure( currentEnvironment, expectedEnvironmentPairingRevision, @@ -161,7 +187,7 @@ export async function subscribeRuntimeEnvironment( }, isCurrent: () => boolean = () => true ): Promise { - const environment = await resolveManagedRuntimeEnvironment(userDataPath, selector) + const environment = resolveEnvironment(userDataPath, selector) const pairing = getPreferredPairingOffer(environment) const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS let markedUsed = false @@ -235,3 +261,13 @@ function markEnvironmentUsedFromResponse( function shouldUseCachedRequestConnection(method: string): boolean { return method === 'terminal.send' || method === 'terminal.updateViewport' } + +function shouldUseSharedControlEnvelope( + method: string, + params: unknown, + envelope: RuntimeOrchestrationEnvelope | undefined +): RuntimeOrchestrationEnvelope | undefined { + return envelope && method.startsWith('orchestration.') && !isOrchestrationMutation(method, params) + ? envelope + : undefined +} diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index b1dc936acf8..6919c68f198 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -152,18 +152,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:retryConnectionsNow', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe' ]) @@ -190,18 +178,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning', 'runtimeEnvironments:retryConnectionsNow' ]) expect(removeAllListenersMock).toHaveBeenCalledWith('runtimeEnvironments:subscriptionBinary') diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index 4d10253c8e8..afb1adf457a 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -485,7 +485,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(deliveredCloses).toEqual([]) }) - it('fences late payloads and duplicate close after full retirement', async () => { + it('suppresses stale payloads from a retired transport but never re-sends its close', async () => { registerRuntimeEnvironmentHandlers(store as never) let transportCallbacks: { onResponse: (response: Record) => void @@ -533,16 +533,11 @@ describe('registerRuntimeEnvironmentHandlers', () => { } ) - await invalidateRuntimeEnvironmentTransport(added.environment.id) + invalidateRuntimeEnvironmentTransport(added.environment.id) expect(retirePairedRuntimeBrowserClientHostEnvironmentMock).toHaveBeenCalledWith( added.environment.id, expect.objectContaining({ message: 'Runtime environment transport was invalidated' }) ) - expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) - expect(senderSend).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', { - subscriptionId: 'multiplex-stale', - type: 'close' - }) senderSend.mockClear() // A late frame from the retired socket must not reach the renderer... transportCallbacks!.onResponse({ diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 3117a81a067..6d16315b3a7 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,5 +1,7 @@ import { app, ipcMain } from 'electron' -import { listEnvironments } from '../../shared/runtime-environment-store' +import { randomUUID } from 'node:crypto' +import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { isRuntimeEnvironmentManuallyDisconnected, @@ -11,22 +13,53 @@ import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler' -import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' -import { resetSharedControlSupport } from './runtime-environment-transport-routing' import { - closeSubscriptionsForEnvironment, - registerRuntimeEnvironmentSubscriptions -} from './runtime-environment-subscriptions' + advanceRuntimeEnvironmentTransportGeneration, + getRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' +import { + resetSharedControlSupport, + subscribeRuntimeEnvironment +} from './runtime-environment-transport-routing' import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' -import { registerOrcadRuntimeLifecycleHandlers } from './orcad-runtime-lifecycle-handlers' -import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers' -import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler' import { advanceRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' +type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { + environmentId: string + ownerWebContentsId: number + removeDestroyedListener: () => void + notifyClosed: () => void +} +const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') +function closeSubscriptionsForEnvironment(environmentId: string): void { + // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. + for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { + if (subscription.environmentId !== environmentId) { + continue + } + remoteRuntimeSubscriptions.delete(subscriptionId) + // Why: one failing teardown must not abandon this environment's other + // sockets -- that strands exactly the dead handles this sweep exists to + // retire. Guard the two steps independently so neither can skip the other, + // and so the isolation stays structural rather than resting on a claim that + // nothing inside notifyClosed will ever throw. + try { + subscription.close() + } catch (error) { + console.warn('[runtime-environments] subscription close failed during retirement:', error) + } + try { + // Why: a shared-control logical close never calls back, so notify directly. + subscription.notifyClosed() + } catch (error) { + console.warn('[runtime-environments] subscription close notice failed:', error) + } + } +} /** Returns once the environment's client-hosted browser pages have been released. */ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Promise { // Why: a same-id re-pair must retire every transport that still authenticates as the old peer. @@ -70,15 +103,193 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).activate() } } - registerRuntimeSshAccessHandlers({ - getUserDataPath, - invalidateTransport: invalidateRuntimeEnvironmentTransport - }) - registerOrcadRuntimeLifecycleHandlers({ getUserDataPath }) - registerOrcadRuntimeMaintenanceHandlers({ - getUserDataPath, - getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId, - invalidateTransport: invalidateRuntimeEnvironmentTransport - }) - registerRuntimeEnvironmentSubscriptions(getUserDataPath) + ipcMain.handle( + 'runtimeEnvironments:subscribe', + async ( + event, + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string + } + ): Promise<{ subscriptionId: string; requestId: string }> => { + const subscriptionId = + typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 + ? args.subscriptionId + : randomUUID() + if (remoteRuntimeSubscriptions.has(subscriptionId)) { + throw new Error('Runtime environment subscription id already exists') + } + const environment = resolveEnvironment(getUserDataPath(), args.selector) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + throw new Error('runtime_manually_disconnected') + } + const pairingRevision = environment.pairingRevision ?? environment.createdAt + if ( + args.expectedEnvironmentPairingRevision !== undefined && + pairingRevision !== args.expectedEnvironmentPairingRevision + ) { + throw new Error('Runtime environment pairing changed; refresh and try again') + } + if ( + args.expectedEnvironmentRuntimeId !== undefined && + environment.runtimeId !== args.expectedEnvironmentRuntimeId + ) { + throw new Error('Runtime environment identity changed; refresh and try again') + } + const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) + const transportIsCurrent = (): boolean => + getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration + const sender = event.sender + const ownerWebContentsId = sender.id + let senderDestroyed = sender.isDestroyed() + let subscription: RemoteRuntimeSubscription | null = null + let destroyedListenerAttached = false + const removeDestroyedListener = (): void => { + if (!destroyedListenerAttached) { + return + } + destroyedListenerAttached = false + sender.removeListener('destroyed', closeSubscription) + } + const closeSubscription = (): void => { + senderDestroyed = true + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + remoteRuntimeSubscriptions.delete(subscriptionId) + if (retained) { + retained.close() + return + } + removeDestroyedListener() + subscription?.close() + } + // Why: the renderer treats close as terminal and drops its handle, so send it once. + // Latch before sending so a re-entrant call cannot duplicate it, and never + // throw: a dying renderer must not abort its siblings' retirement. + let closeNotified = false + const notifyClosed = (): void => { + if (closeNotified || sender.isDestroyed()) { + return + } + closeNotified = true + try { + sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, type: 'close' }) + } catch { + // The renderer is gone; there is no one left to tell. + } + } + sender.once('destroyed', closeSubscription) + destroyedListenerAttached = true + try { + subscription = await subscribeRuntimeEnvironment( + getUserDataPath(), + environment.id, + args.method, + args.params, + args.timeoutMs, + { + onEvent: (payload) => { + if (payload.type === 'close') { + // Why: retirement advances the generation before closing, so gating + // close on it stranded the renderer with a dead subscription. + notifyClosed() + return + } + if (transportIsCurrent() && !sender.isDestroyed()) { + sender.send('runtimeEnvironments:subscriptionEvent', { + subscriptionId, + ...payload + }) + } + }, + onClose: () => { + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + retained?.removeDestroyedListener() + remoteRuntimeSubscriptions.delete(subscriptionId) + } + }, + transportIsCurrent + ) + } catch (error) { + removeDestroyedListener() + throw error + } + let pairingIsCurrent = false + try { + const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) + pairingIsCurrent = + (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision + } catch { + pairingIsCurrent = false + } + if (!transportIsCurrent() || !pairingIsCurrent) { + removeDestroyedListener() + subscription.close() + throw new Error('Runtime environment pairing changed; refresh and try again') + } + if (senderDestroyed || sender.isDestroyed()) { + removeDestroyedListener() + subscription.close() + return { subscriptionId, requestId: subscription.requestId } + } + remoteRuntimeSubscriptions.set(subscriptionId, { + requestId: subscription.requestId, + environmentId: environment.id, + ownerWebContentsId, + removeDestroyedListener, + notifyClosed, + sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, + close: () => { + removeDestroyedListener() + subscription?.close() + } + }) + return { subscriptionId, requestId: subscription.requestId } + } + ) + ipcMain.handle( + 'runtimeEnvironments:unsubscribe', + (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { + return { unsubscribed: false } + } + remoteRuntimeSubscriptions.delete(args.subscriptionId) + subscription.close() + return { unsubscribed: true } + } + ) + ipcMain.on( + 'runtimeEnvironments:subscriptionBinary', + (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { + if (typeof args.subscriptionId !== 'string') { + return + } + const bytes = toBinaryPayload(args.bytes) + if (!bytes) { + return + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (subscription?.ownerWebContentsId === event.sender.id) { + subscription.sendBinary(bytes) + } + } + ) +} + +function toBinaryPayload(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) { + return value + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + return null } diff --git a/src/main/ipc/runtime-ssh-access-handlers.test.ts b/src/main/ipc/runtime-ssh-access-handlers.test.ts deleted file mode 100644 index ecf5d2de21e..00000000000 --- a/src/main/ipc/runtime-ssh-access-handlers.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ handle: vi.fn(), link: vi.fn(), unlink: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/runtime-ssh-access', () => ({ - linkRuntimeSshAccess: mocks.link, - unlinkRuntimeSshAccess: mocks.unlink -})) -import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' - -describe('existing paired server SSH access IPC', () => { - const invalidateTransport = vi.fn() - const request = { - selector: 'host', - requestId: 'request-1', - sshTargetId: 'ssh-host', - remotePort: 6768 - } - beforeEach(() => { - vi.clearAllMocks() - registerRuntimeSshAccessHandlers({ - getUserDataPath: () => '/test-profile', - invalidateTransport - }) - }) - function handler(channel: string): (_event: null, input: unknown) => Promise { - const registered = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registered) { - throw new Error('Handler missing') - } - return registered[1] - } - - it('registers access-only actions and forwards only validated arguments and main-owned invalidation', async () => { - expect(mocks.handle.mock.calls.map(([name]) => name)).toEqual([ - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess' - ]) - const result = { - id: 'host', - endpoints: [{ id: 'ssh-endpoint', endpoint: 'ws://127.0.0.1:41000' }] - } - mocks.link.mockResolvedValue(result) - expect(await handler('runtimeEnvironments:linkSshAccess')(null, request)).toBe(result) - expect(mocks.link).toHaveBeenCalledExactlyOnceWith('/test-profile', request, { - invalidateTransport - }) - await handler('runtimeEnvironments:unlinkSshAccess')(null, { - selector: 'host', - requestId: 'unlink-1' - }) - expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith( - '/test-profile', - { selector: 'host', requestId: 'unlink-1' }, - { invalidateTransport } - ) - }) - - it.each([ - { remotePort: 0 }, - { remotePort: 65536 }, - { requestId: '../request' }, - { verifiedRuntimeId: 'renderer-supplied' }, - { userDataPath: '/other-profile' }, - { owner: { type: 'orcad-runtime', environmentId: 'other' } } - ])('refuses invalid or authority-bearing link arguments: %j', async (change) => { - await expect( - handler('runtimeEnvironments:linkSshAccess')(null, { ...request, ...change }) - ).rejects.toThrow() - expect(mocks.link).not.toHaveBeenCalled() - }) - - it('refuses renderer-supplied unlink snapshots', async () => { - await expect( - handler('runtimeEnvironments:unlinkSshAccess')(null, { - selector: 'host', - requestId: 'unlink-1', - expectedEnvironment: { id: 'other' } - }) - ).rejects.toThrow() - expect(mocks.unlink).not.toHaveBeenCalled() - }) - - it('propagates pending failures instead of reporting a successful link', async () => { - const error = new Error('SSH endpoint identity was not verified') - mocks.link.mockRejectedValue(error) - await expect(handler('runtimeEnvironments:linkSshAccess')(null, request)).rejects.toBe(error) - }) -}) diff --git a/src/main/ipc/runtime-ssh-access-handlers.ts b/src/main/ipc/runtime-ssh-access-handlers.ts deleted file mode 100644 index deed2936f35..00000000000 --- a/src/main/ipc/runtime-ssh-access-handlers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ipcMain } from 'electron' -import { - RuntimeSshAccessLinkRequestSchema, - RuntimeSshAccessUnlinkRequestSchema -} from '../../shared/runtime-ssh-access' -import { linkRuntimeSshAccess, unlinkRuntimeSshAccess } from '../ssh/runtime-ssh-access' - -export function registerRuntimeSshAccessHandlers(options: { - getUserDataPath: () => string - invalidateTransport: (environmentId: string) => void | Promise -}): void { - ipcMain.handle('runtimeEnvironments:linkSshAccess', async (_event, input: unknown) => { - const args = RuntimeSshAccessLinkRequestSchema.parse(input) - return linkRuntimeSshAccess(options.getUserDataPath(), args, { - invalidateTransport: options.invalidateTransport - }) - }) - ipcMain.handle('runtimeEnvironments:unlinkSshAccess', async (_event, input: unknown) => { - const args = RuntimeSshAccessUnlinkRequestSchema.parse(input) - return unlinkRuntimeSshAccess(options.getUserDataPath(), args, { - invalidateTransport: options.invalidateTransport - }) - }) -} diff --git a/src/main/ipc/runtime-watcher-disposal-owners.test.ts b/src/main/ipc/runtime-watcher-disposal-owners.test.ts deleted file mode 100644 index a889db510a4..00000000000 --- a/src/main/ipc/runtime-watcher-disposal-owners.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeWatcherDisposalOwners } from './runtime-watcher-disposal-owners' - -function owner() { - return { - dispose: vi.fn(), - subscribe: vi.fn(), - disposeAndWait: vi.fn(async () => {}) - } -} - -it('retains retired cleanup and joins concurrent shutdown callers', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const retired = owner() - const pending = Promise.withResolvers() - retired.disposeAndWait.mockReturnValue(pending.promise) - owners.retire(retired) - const shutdown = owners.disposeAndWait(() => {}) - expect(owners.disposeAndWait(() => {})).toBe(shutdown) - const finished = vi.fn() - const result = shutdown.then(finished) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - expect(retired.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - await result - await owners.disposeAndWait(() => {}) - expect(retired.disposeAndWait).toHaveBeenCalledOnce() -}) - -it('does not retry a newly failed attempt until a later explicit shutdown call', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const failed = owner() - const sibling = owner() - const pending = Promise.withResolvers() - const failure = new Error('child still live') - failed.disposeAndWait.mockRejectedValueOnce(failure) - sibling.disposeAndWait.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = owners.disposeAndWait(() => { - owners.retire(failed) - owners.retire(sibling) - }) - const result = shutdown.catch((error: unknown) => { - finished() - return error - }) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - expect(owners.disposeAndWait(() => {})).toBe(shutdown) - expect(failed.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - expect(await result).toMatchObject({ errors: [failure] }) - await owners.disposeAndWait(() => {}) - expect(failed.disposeAndWait).toHaveBeenCalledTimes(2) - expect(sibling.disposeAndWait).toHaveBeenCalledOnce() -}) - -it('retains synchronous failures and attempts sibling owners', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const failed = owner() - const sibling = owner() - failed.disposeAndWait.mockImplementationOnce(() => { - throw new Error('sync failure') - }) - await expect( - owners.disposeAndWait(() => { - owners.retire(failed) - owners.retire(sibling) - }) - ).rejects.toThrow('watcher_pool_shutdown_incomplete') - expect(sibling.disposeAndWait).toHaveBeenCalledOnce() - await owners.disposeAndWait(() => {}) - expect(failed.disposeAndWait).toHaveBeenCalledTimes(2) -}) - -it('publishes the retained attempt before a synchronous disposal callback reenters', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const child = owner() - const pending = Promise.withResolvers() - let nested: Promise | undefined - child.disposeAndWait.mockImplementation(() => { - owners.retire(child) - nested = owners.disposeAndWait(() => {}) - return pending.promise - }) - const shutdown = owners.disposeAndWait(() => owners.retire(child)) - expect(nested).toBe(shutdown) - expect(child.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - await shutdown -}) - -it('refuses to acknowledge a supervisor without a physical-disposal API', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const legacy = { dispose: vi.fn(), subscribe: vi.fn() } - await expect(owners.disposeAndWait(() => owners.retire(legacy))).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ message: 'watcher_supervisor_awaited_disposal_unavailable' }) - ] - }) - expect(legacy.dispose).toHaveBeenCalledOnce() -}) diff --git a/src/main/ipc/runtime-watcher-disposal-owners.ts b/src/main/ipc/runtime-watcher-disposal-owners.ts deleted file mode 100644 index e88c741844f..00000000000 --- a/src/main/ipc/runtime-watcher-disposal-owners.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { RuntimeWatcherPoolSupervisor } from './runtime-watcher-pool-state' - -type Attempt = { pending: Promise | null; failure?: unknown } - -type Completion = { promise: Promise; resolve: () => void; reject: (error: unknown) => void } - -// Why not Promise.withResolvers: the relay bundles this pool and still targets Node 18 hosts. -function createCompletion(): Completion { - let resolve!: () => void - let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -export class RuntimeWatcherDisposalOwners { - private readonly retained = new Map() - private shutdown: Promise | null = null - - retire(owner: RuntimeWatcherPoolSupervisor): void { - if (!this.retained.has(owner)) { - this.start(owner) - } - } - - disposeAndWait(disposePool: () => void): Promise { - if (this.shutdown) { - return this.shutdown - } - const retry = [...this.retained].filter(([, attempt]) => !attempt.pending) - const completion = createCompletion() - this.shutdown = completion.promise - const failures: unknown[] = [] - try { - disposePool() - } catch (error) { - failures.push(error) - } - for (const [owner, attempt] of retry) { - if (this.retained.get(owner) === attempt) { - this.start(owner) - } - } - const pending = [...this.retained.values()].map( - (attempt) => attempt.pending ?? Promise.reject(attempt.failure) - ) - void Promise.allSettled(pending).then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - failures.push(result.reason) - } - } - this.shutdown = null - if (failures.length > 0) { - completion.reject(new AggregateError(failures, 'watcher_pool_shutdown_incomplete')) - } else { - completion.resolve() - } - }) - return completion.promise - } - - private start(owner: RuntimeWatcherPoolSupervisor): void { - const completion = createCompletion() - const attempt: Attempt = { pending: completion.promise } - this.retained.set(owner, attempt) - void completion.promise.catch(() => {}) - const failed = (error: unknown): void => { - attempt.pending = null - attempt.failure = error - completion.reject(error) - } - try { - if (!owner.disposeAndWait) { - owner.dispose() - throw new Error('watcher_supervisor_awaited_disposal_unavailable') - } - void owner.disposeAndWait().then(() => { - if (this.retained.get(owner) === attempt) { - this.retained.delete(owner) - } - completion.resolve() - }, failed) - } catch (error) { - failed(error) - } - } -} diff --git a/src/main/ipc/runtime-watcher-pool-shutdown.test.ts b/src/main/ipc/runtime-watcher-pool-shutdown.test.ts deleted file mode 100644 index 1f37f3540c5..00000000000 --- a/src/main/ipc/runtime-watcher-pool-shutdown.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool' -import type { WatcherProcessHooks } from './parcel-watcher-process-subscription' -import { WatcherProcessFailure } from './parcel-watcher-process-failure' - -function supervisor() { - const pending = Promise.withResolvers() - let hooks: WatcherProcessHooks | undefined - return { - pending, - dispose: vi.fn(), - disposeAndWait: vi.fn(() => pending.promise), - subscribe: vi.fn( - async ( - _dir: string, - _callback: unknown, - _options: unknown, - options?: WatcherProcessHooks - ) => { - hooks = options - return { unsubscribe: vi.fn(async () => {}) } - } - ), - fail: () => - hooks?.onTerminalError?.( - new WatcherProcessFailure('failed', 'supervisor', 'process_unavailable') - ) - } -} - -it('awaits retired and replacement supervisors after logical retirement removed the old slot', async () => { - const old = supervisor() - const current = supervisor() - const create = vi.fn().mockReturnValueOnce(old).mockReturnValueOnce(current) - const pool = new RuntimeWatcherProcessPool({ createSupervisor: create }) - await pool.subscribe('/first', vi.fn(), {}) - old.fail() - await new Promise((resolve) => setImmediate(resolve)) - expect(old.disposeAndWait).toHaveBeenCalledOnce() - await pool.subscribe('/second', vi.fn(), {}) - const finished = vi.fn() - const shutdown = pool.disposeAndWait() - expect(pool.disposeAndWait()).toBe(shutdown) - const result = shutdown.then(finished) - current.pending.resolve() - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(pool.subscribe('/third', vi.fn(), {})).rejects.toThrow('disposed') - expect(create).toHaveBeenCalledTimes(2) - old.pending.resolve() - await result -}) - -it('retains failure after synchronous pool disposal and retries it explicitly', async () => { - const child = supervisor() - const failure = new Error('child still running') - child.disposeAndWait.mockRejectedValueOnce(failure) - const pool = new RuntimeWatcherProcessPool({ createSupervisor: () => child }) - await pool.subscribe('/first', vi.fn(), {}) - const shutdown = pool.disposeAndWait() - await expect(shutdown).rejects.toMatchObject({ errors: [failure] }) - const retry = pool.disposeAndWait() - expect(child.disposeAndWait).toHaveBeenCalledTimes(2) - child.pending.resolve() - await retry -}) - -it('does not duplicate termination when a queued retirement races with synchronous disposal', async () => { - const child = supervisor() - const pool = new RuntimeWatcherProcessPool({ createSupervisor: () => child }) - await pool.subscribe('/first', vi.fn(), {}) - child.fail() - pool.dispose() - const shutdown = pool.disposeAndWait() - await new Promise((resolve) => setImmediate(resolve)) - expect(child.disposeAndWait).toHaveBeenCalledOnce() - child.pending.resolve() - await shutdown -}) diff --git a/src/main/ipc/runtime-watcher-pool-state.ts b/src/main/ipc/runtime-watcher-pool-state.ts index a99c9b93895..e6c016b9844 100644 --- a/src/main/ipc/runtime-watcher-pool-state.ts +++ b/src/main/ipc/runtime-watcher-pool-state.ts @@ -1,7 +1,6 @@ import type { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' -export type RuntimeWatcherPoolSupervisor = Pick & - Partial> +export type RuntimeWatcherPoolSupervisor = Pick export type RuntimeWatcherPoolSlot = { supervisor: RuntimeWatcherPoolSupervisor @@ -11,13 +10,6 @@ export type RuntimeWatcherPoolSlot = { disposed: boolean } -export function activeWatcherSlots( - slots: ReadonlySet, - isolated: boolean -): RuntimeWatcherPoolSlot[] { - return [...slots].filter((slot) => slot.isolated === isolated && !slot.retired) -} - export type RuntimeWatcherPoolAssignment = { slot: RuntimeWatcherPoolSlot leases: number diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts index 852fd5abdf4..4bafc38208d 100644 --- a/src/main/ipc/runtime-watcher-process-pool.ts +++ b/src/main/ipc/runtime-watcher-process-pool.ts @@ -7,17 +7,15 @@ import type { WatcherProcessSubscription } from './parcel-watcher-process-subscription' import { RuntimeWatcherPendingAssignment } from './runtime-watcher-pending-assignment' -import { RuntimeWatcherDisposalOwners } from './runtime-watcher-disposal-owners' import { RuntimeWatcherPoolLifecycle } from './runtime-watcher-pool-lifecycle' import { RuntimeWatcherPredecessorBarriers } from './runtime-watcher-predecessor-barriers' import { RuntimeWatcherQuarantineQueue } from './runtime-watcher-quarantine-queue' import { handleRuntimeWatcherSubscriptionFailure } from './runtime-watcher-subscription-failure' -import { - activeWatcherSlots, - type RuntimeWatcherPoolAssignment, - type RuntimeWatcherPoolSlot, - type RuntimeWatcherPoolSupervisor, - type RuntimeWatcherProcessPoolOptions +import type { + RuntimeWatcherPoolAssignment, + RuntimeWatcherPoolSlot, + RuntimeWatcherPoolSupervisor, + RuntimeWatcherProcessPoolOptions } from './runtime-watcher-pool-state' export type { RuntimeWatcherProcessPoolOptions } from './runtime-watcher-pool-state' @@ -41,7 +39,6 @@ export class RuntimeWatcherProcessPool { RuntimeWatcherPendingAssignment >() private readonly lifecycle = new RuntimeWatcherPoolLifecycle() - private disposalOwners = new RuntimeWatcherDisposalOwners() private readonly predecessorBarriers = new RuntimeWatcherPredecessorBarriers() private readonly quarantineQueue: RuntimeWatcherQuarantineQueue @@ -145,12 +142,9 @@ export class RuntimeWatcherProcessPool { resetForTest(): void { this.dispose() - this.disposalOwners = new RuntimeWatcherDisposalOwners() this.lifecycle.reset() } - disposeAndWait = (): Promise => this.disposalOwners.disposeAndWait(() => this.dispose()) - forgetRoot(dir: string): void { // Physical subscriptions release their assignment through unsubscribe or // terminal callbacks. This clears fault history after setup gives up. @@ -227,7 +221,9 @@ export class RuntimeWatcherProcessPool { } private sharedSlot(): RuntimeWatcherPoolSlot { - const sharedSlots = activeWatcherSlots(this.activeSlots, false) + const sharedSlots = Array.from(this.activeSlots).filter( + (slot) => !slot.isolated && !slot.retired + ) if (sharedSlots.length < this.maxSharedSupervisors) { return this.createSlot(false) } @@ -237,7 +233,9 @@ export class RuntimeWatcherProcessPool { } private quarantineSlot(dir: string): RuntimeWatcherPoolSlot | Promise { - const quarantineSlots = activeWatcherSlots(this.activeSlots, true) + const quarantineSlots = Array.from(this.activeSlots).filter( + (slot) => slot.isolated && !slot.retired + ) if (quarantineSlots.length < this.maxQuarantineSupervisors) { return this.createSlot(true) } @@ -275,11 +273,7 @@ export class RuntimeWatcherProcessPool { slot.roots.clear() // Why: failAllSubscriptions is still iterating callbacks; defer disposal // so every logical root receives the supervisor failure first. - const owners = this.disposalOwners queueMicrotask(() => { - if (this.disposalOwners !== owners) { - return - } this.disposeSlot(slot) this.drainQuarantineWaiters() }) @@ -314,7 +308,8 @@ export class RuntimeWatcherProcessPool { private drainQuarantineWaiters(): void { while ( this.quarantineQueue.length > 0 && - activeWatcherSlots(this.activeSlots, true).length < this.maxQuarantineSupervisors + Array.from(this.activeSlots).filter((slot) => slot.isolated && !slot.retired).length < + this.maxQuarantineSupervisors ) { this.quarantineQueue.grantNext(this.createSlot(true)) } @@ -325,7 +320,7 @@ export class RuntimeWatcherProcessPool { return } slot.disposed = true - this.disposalOwners.retire(slot.supervisor) + slot.supervisor.dispose() this.allSlots.delete(slot) } } diff --git a/src/main/ipc/ssh-active-relay-sessions.ts b/src/main/ipc/ssh-active-relay-sessions.ts index 3c12496f7d4..69b781be41d 100644 --- a/src/main/ipc/ssh-active-relay-sessions.ts +++ b/src/main/ipc/ssh-active-relay-sessions.ts @@ -1,8 +1,5 @@ import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { - setDirectSshAuthorityResolver, - setSshActiveMultiplexerResolver -} from '../ssh/ssh-target-registry' +import { setSshActiveMultiplexerResolver } from '../ssh/ssh-target-registry' import { setWorktreeRemovalSshHostHomeResolver } from '../worktree-removal-execution-host-route' // One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). @@ -25,5 +22,3 @@ export function getActiveSshHostHomeDirectory(targetId: string): string | null { } setWorktreeRemovalSshHostHomeResolver(getActiveSshHostHomeDirectory) - -setDirectSshAuthorityResolver((targetId) => activeSessions.has(targetId)) diff --git a/src/main/ipc/ssh-connect-attempt-registry.ts b/src/main/ipc/ssh-connect-attempt-registry.ts index 5aba526d683..5a1e3632982 100644 --- a/src/main/ipc/ssh-connect-attempt-registry.ts +++ b/src/main/ipc/ssh-connect-attempt-registry.ts @@ -44,44 +44,3 @@ export const resetRelayInFlight = new Map>() // Why: ssh:testConnection connects then disconnects; suppressing broadcasts during the test avoids worktree cards flashing connected → disconnected. export const testingTargets = new Set() export const testConnectionProbes = new Set>() -const testConnectionProbesByTarget = new Map>>() - -export function hasSshTestConnectionProbes(targetId: string): boolean { - return (testConnectionProbesByTarget.get(targetId)?.size ?? 0) > 0 -} - -export function runSshTestConnectionProbe( - targetId: string, - operation: () => Promise -): Promise { - const probes = testConnectionProbesByTarget.get(targetId) ?? new Set>() - let probe!: Promise - // Publish before connection callbacks can start a reset or shutdown drain. - probe = Promise.resolve() - .then(operation) - .finally(() => { - testConnectionProbes.delete(probe) - probes.delete(probe) - if (probes.size === 0 && testConnectionProbesByTarget.get(targetId) === probes) { - testConnectionProbesByTarget.delete(targetId) - testingTargets.delete(targetId) - credentialRequestedForTarget.delete(targetId) - } - }) - probes.add(probe) - testConnectionProbesByTarget.set(targetId, probes) - testConnectionProbes.add(probe) - testingTargets.add(targetId) - return probe -} - -/** Reserve target admission before joining; failed probes still own cleanup until settled. */ -export async function awaitSshTestConnectionProbes(targetId: string): Promise { - while (true) { - const probes = testConnectionProbesByTarget.get(targetId) - if (!probes?.size) { - return - } - await Promise.allSettled(probes) - } -} diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 946dfed7bf9..9ea533c77c3 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -23,7 +23,8 @@ import { credentialRequestedForTarget, invalidateConnectAttempt, resetRelayInFlight, - runSshTestConnectionProbe + testConnectionProbes, + testingTargets } from './ssh-connect-attempt-registry' import { connectTarget } from './ssh-connect-flow' import { connectionManager, persistedStore } from './ssh-ipc-context' @@ -265,16 +266,18 @@ export function registerSshConnectionHandlers(): void { } } + testingTargets.add(args.targetId) // Why a tracked promise and not just the id: a probe holds a real transport that no session owns, // so shutdown has to be able to join it before the final drain disconnects what is left. - const probe = runSshTestConnectionProbe(args.targetId, async () => { + const probe = (async () => { // Why: a probe transport opened after the shutdown drain would outlive orderly teardown. assertSshConnectsNotFenced() const conn = await connectionManager!.connect(target) const state = conn.getState() await connectionManager!.disconnect(args.targetId) return state - }) + })() + testConnectionProbes.add(probe) try { return { success: true, state: await probe } } catch (err) { @@ -282,6 +285,11 @@ export function registerSshConnectionHandlers(): void { success: false, error: err instanceof Error ? err.message : String(err) } + } finally { + testConnectionProbes.delete(probe) + testingTargets.delete(args.targetId) + // Why: clear so a test's credential prompt doesn't leave lastRequiredPassphrase=true and defer this target at startup. + credentialRequestedForTarget.delete(args.targetId) } }) } diff --git a/src/main/ipc/ssh-host-sleep-reconnect.test.ts b/src/main/ipc/ssh-host-sleep-reconnect.test.ts index e0f9721e6e0..1994e8c1a0e 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.test.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.test.ts @@ -4,16 +4,12 @@ const manager = vi.hoisted(() => { const connection = {} return { getConnection: vi.fn(() => connection), reconnect: vi.fn(async () => {}) } }) -const recoverManagedTunnels = vi.hoisted(() => vi.fn(async () => {})) vi.mock('electron', async () => { const { EventEmitter } = await import('node:events') return { powerMonitor: new EventEmitter() } }) vi.mock('./ssh-ipc-context', () => ({ connectionManager: manager })) -vi.mock('../ssh/orcad-managed-tunnel', () => ({ - recoverOrcadManagedTunnelsAfterHostResume: recoverManagedTunnels -})) import { powerMonitor } from 'electron' import { activeSessions } from './ssh-active-relay-sessions' @@ -60,24 +56,4 @@ describe('host sleep reconnect in plain SSH mode', () => { powerMonitor.emit('resume') await vi.waitFor(() => expect(manager.reconnect).toHaveBeenCalledWith('target-1')) }) - - it('recovers managed tunnels with the same probe policy, even with no relay sessions', async () => { - registerPowerMonitorReconnect(() => '/user-data') - powerMonitor.emit('resume') - await vi.waitFor(() => - expect(recoverManagedTunnels).toHaveBeenCalledWith('/user-data', { - attempts: 2, - timeoutMs: 5_000 - }) - ) - }) - - it('leaves managed tunnels alone when the caller supplies no profile path', async () => { - const session = plainSession(async () => true) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) - registerPowerMonitorReconnect() - await resumeAndSettle() - expect(recoverManagedTunnels).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/ssh-host-sleep-reconnect.ts b/src/main/ipc/ssh-host-sleep-reconnect.ts index 3c559059f45..731946e3cfc 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.ts @@ -1,5 +1,4 @@ import { powerMonitor } from 'electron' -import { recoverOrcadManagedTunnelsAfterHostResume } from '../ssh/orcad-managed-tunnel' import type { SshRelaySession } from '../ssh/ssh-relay-session' import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager } from './ssh-ipc-context' @@ -33,7 +32,7 @@ async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise string): void { +export function registerPowerMonitorReconnect(): void { powerMonitorUnsubscribe?.() const onSuspend = (): void => { for (const session of activeSessions.values()) { @@ -67,19 +66,6 @@ export function registerPowerMonitorReconnect(getUserDataPath?: () => string): v } })() } - // Why separate: managed tunnels ride their own connections, not relay sessions. - if (getUserDataPath) { - void recoverOrcadManagedTunnelsAfterHostResume(getUserDataPath(), { - attempts: RESUME_PROBE_ATTEMPTS, - timeoutMs: RESUME_PROBE_TIMEOUT_MS - }).catch((err) => { - console.warn( - `[ssh] Failed to recover a managed Orca tunnel after system resume: ${ - err instanceof Error ? err.message : String(err) - }` - ) - }) - } } powerMonitor.on('suspend', onSuspend) powerMonitor.on('resume', onResume) diff --git a/src/main/ipc/ssh-ipc-module-mocks.ts b/src/main/ipc/ssh-ipc-module-mocks.ts index af05f0b1204..0855a739fb2 100644 --- a/src/main/ipc/ssh-ipc-module-mocks.ts +++ b/src/main/ipc/ssh-ipc-module-mocks.ts @@ -144,7 +144,6 @@ export function createSshIpcMocks(): SshIpcMocks { installSshPtySourceCancellationPublisher: vi.fn().mockReturnValue(() => {}) }, sshConnectionStore: { - isRuntimeOwnedSshTarget: (target: { owner?: unknown }) => target.owner !== undefined, SshConnectionStore: class MockSshConnectionStore { constructor() { return mockSshStore diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 8dbc1cfede1..999b8e9a346 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -12,10 +12,8 @@ import { enrichSshForwardEntries, getWorktreeIdsForConnection } from '../ports/ssh-advertised-url-enrichment' -import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager, @@ -32,9 +30,8 @@ export function broadcastSshState( targetId: string, state: SshConnectionState ): void { - const target = getSshTargetRegistryStore()?.getTarget(targetId) - // Why: owned targets are hidden from clients; broadcasting them leaks internal transport state into persisted reconnect hints. - if (isRuntimeOwnedSshTargetId(targetId) || (target && isRuntimeOwnedSshTarget(target))) { + // Why: runtime-owned (ephemeral-VM) targets are hidden from the renderer, so broadcasting their state only triggers wasted listTargets() lookups. + if (isRuntimeOwnedSshTargetId(targetId)) { currentRuntime?.invalidateSshWorktreeScanCache?.(targetId) return } diff --git a/src/main/ipc/ssh-shutdown-drain.ts b/src/main/ipc/ssh-shutdown-drain.ts index 93a25071bfc..f66db066c01 100644 --- a/src/main/ipc/ssh-shutdown-drain.ts +++ b/src/main/ipc/ssh-shutdown-drain.ts @@ -61,34 +61,22 @@ async function settleTasksWithinMs( return { timedOut: [...pending], errors } } -/** Which targets the quit drain may detach; a target it may not keeps its session and transport. */ -export type SshShutdownDetachPredicate = (targetId: string) => boolean - -const detachEveryTarget: SshShutdownDetachPredicate = () => true - -function sshShutdownTasks( - targetIds: readonly string[], - mayDetach: SshShutdownDetachPredicate -): SshShutdownTask[] { +function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] { return [ ...targetIds - .filter((targetId) => activeSessions.has(targetId) && mayDetach(targetId)) + .filter((targetId) => activeSessions.has(targetId)) .map((targetId) => ({ targetId, promise: teardownActiveSshSession(targetId, (session) => session.detachAndPersist()) })), - { - targetId: '*transports', - promise: connectionManager?.disconnectAll(mayDetach) ?? Promise.resolve() - } + { targetId: '*transports', promise: connectionManager?.disconnectAll() ?? Promise.resolve() } ] } async function drainSshShutdown( targetIds: readonly string[], inFlight: readonly SshShutdownTask[], - detachErrors: readonly unknown[] = [], - mayDetach: SshShutdownDetachPredicate = detachEveryTarget + detachErrors: readonly unknown[] = [] ): Promise { const deadline = Date.now() + SSH_SHUTDOWN_BUDGET_MS const unfinished: SshShutdownUnfinished[] = [] @@ -108,12 +96,12 @@ async function drainSshShutdown( return settled.timedOut.length === 0 } - await runPhase('drain', sshShutdownTasks(targetIds, mayDetach)) + await runPhase('drain', sshShutdownTasks(targetIds)) // Why a second drain after the join: a connect paused in old-session teardown still publishes its // replacement session and opens a transport before it reaches the cancellation checkpoint, so the // first drain can miss both. if (await runPhase('in-flight-join', inFlight)) { - await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()], mayDetach)) + await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()])) } if (errors.length > 0 || unfinished.length > 0) { @@ -135,9 +123,7 @@ async function drainSshShutdown( // // Why no fence latch here: the committed quit path sets it before calling this, so it is already on // for the snapshot below. Called without that gate (tests), this degrades to a plain drain. -export function beginSshShutdown( - mayDetach: SshShutdownDetachPredicate = detachEveryTarget -): Promise { +export function beginSshShutdown(): Promise { if (sshShutdownDrain) { return sshShutdownDrain } @@ -150,18 +136,13 @@ export function beginSshShutdown( ...[...testConnectionProbes].map((promise) => ({ targetId: '*probe', promise })) ] for (const targetId of Array.from(connectInFlight.keys())) { - if (mayDetach(targetId)) { - invalidateConnectAttempt(targetId) - } + invalidateConnectAttempt(targetId) } const targetIds = [...activeSessions.keys()] // Why before any await: this is the whole point of the split. Each session marks its recovery lease // detached in memory now, and the final flush persists it — the remote PTYs keep running. const detachErrors: unknown[] = [] - for (const [targetId, session] of activeSessions) { - if (!mayDetach(targetId)) { - continue - } + for (const session of activeSessions.values()) { // Why per-session: this runs synchronously inside a non-async will-quit listener, so one throw // (teardownProviders -> webContents.send on a destroyed renderer, routine on quit) would escape // it and skip every later session, the drain assignment, and the store flush that persists all @@ -172,6 +153,6 @@ export function beginSshShutdown( detachErrors.push(error) } } - sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors, mayDetach) + sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors) return sshShutdownDrain } diff --git a/src/main/ipc/ssh-state-broadcast-fanout.test.ts b/src/main/ipc/ssh-state-broadcast-fanout.test.ts index 9c9ae661237..e7f6480c261 100644 --- a/src/main/ipc/ssh-state-broadcast-fanout.test.ts +++ b/src/main/ipc/ssh-state-broadcast-fanout.test.ts @@ -175,41 +175,6 @@ describe('SSH IPC handlers', () => { expect(runtime.notifySshStateChanged).not.toHaveBeenCalled() }) - it('keeps claimed managed-orcad SSH state off clients under the original target id', () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn(), - invalidateSshWorktreeScanCache: vi.fn(), - notifySshStateChanged: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Managed orcad host', - host: 'example.com', - port: 22, - username: 'deploy', - owner: { type: 'on-demand-runtime', runtimeId: 'managed-orcad:environment-1' } - } satisfies SshTarget) - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - expect(runtime.invalidateSshWorktreeScanCache).toHaveBeenCalledWith('ssh-1') - expect(runtime.notifySshStateChanged).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'ssh:state-changed', - expect.anything() - ) - }) - it('invalidates runtime scans from hidden SSH state broadcasts', () => { const runtime = { onPtyData: vi.fn(), diff --git a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts deleted file mode 100644 index 522e7487619..00000000000 --- a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' - -const mocks = vi.hoisted(() => { - const state: { target?: SshTarget } = {} - return { handle: vi.fn(), remove: vi.fn(), state, addTarget: vi.fn(), updateTarget: vi.fn() } -}) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/ssh-target-registry', () => ({ - getSshTargetRegistryStore: () => ({ - getTarget: () => mocks.state.target, - addTarget: mocks.addTarget, - updateTarget: mocks.updateTarget, - lastRepoReadoptions: [] - }) -})) -vi.mock('./ssh-session-teardown', () => ({ removeRegisteredSshTarget: mocks.remove })) -vi.mock('./ssh-ipc-context', () => ({ getCurrentMainWindow: () => null })) - -const { registerSshTargetCrudHandlers } = await import('./ssh-target-crud-handlers') - -function handler(channel: string): (_event: unknown, args: unknown) => unknown { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -const target: SshTarget = { id: 'ssh-1', label: 'host', host: 'host', port: 22, username: 'dev' } - -describe('SSH target CRUD against managed orcad targets', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.state.target = { ...target, owner: createManagedOrcadSshOwner('environment-1') } - registerSshTargetCrudHandlers() - }) - - it('refuses to edit or remove a target a managed server owns', async () => { - expect(() => - handler('ssh:updateTarget')(null, { id: 'ssh-1', updates: { host: 'elsewhere' } }) - ).toThrow('cannot be edited') - await expect(handler('ssh:removeTarget')(null, { id: 'ssh-1' })).rejects.toThrow( - 'cannot be removed' - ) - expect(mocks.updateTarget).not.toHaveBeenCalled() - expect(mocks.remove).not.toHaveBeenCalled() - }) - - it('refuses a target with a pending provisioning request too', () => { - mocks.state.target = { - ...target, - orcadProvisioning: { requestId: 'request-1', name: 'Managed' } - } - expect(() => handler('ssh:updateTarget')(null, { id: 'ssh-1', updates: {} })).toThrow( - 'cannot be edited' - ) - }) - - it('never lets the renderer write a provisioning intent', () => { - mocks.state.target = target - handler('ssh:updateTarget')(null, { - id: 'ssh-1', - updates: { label: 'renamed', orcadProvisioning: { requestId: 'x', name: 'y' } } - }) - handler('ssh:addTarget')(null, { - target: { ...target, orcadProvisioning: { requestId: 'x', name: 'y' } } - }) - expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { label: 'renamed' }) - expect(mocks.addTarget.mock.calls[0]?.[0]).not.toHaveProperty('orcadProvisioning') - }) -}) diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 3a045f32107..e8b7a95c478 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -11,7 +11,6 @@ import { } from '../ssh/ssh-config-host-picker' import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getCurrentMainWindow } from './ssh-ipc-context' import { removeRegisteredSshTarget } from './ssh-session-teardown' @@ -35,26 +34,18 @@ function takeRepoReadoptions(): SshRepoReadoption[] { return repoReadoptions } -// Why: generations, provisioning and the runtime ladder cache are main-owned; a renderer must not forge them. +// Why: generations and the runtime ladder cache are main-owned; a renderer must not forge them. function omitRendererSshTargetGeneration< - T extends { generation?: unknown; orcadProvisioning?: unknown; remoteRuntimeResolution?: unknown } ->(value: T): Omit { + T extends { generation?: unknown; remoteRuntimeResolution?: unknown } +>(value: T): Omit { const { generation: _generation, - orcadProvisioning: _orcadProvisioning, remoteRuntimeResolution: _remoteRuntimeResolution, ...rest } = value return rest } -function assertNotRuntimeOwned(targetId: string, action: string): void { - const target = getSshTargetRegistryStore()!.getTarget(targetId) - if (target && isRuntimeOwnedSshTarget(target)) { - throw new Error(`Managed runtime SSH targets cannot be ${action} from SSH settings.`) - } -} - export function registerSshTargetCrudHandlers(): void { ipcMain.handle('ssh:listTargets', () => { return getSshTargetRegistryStore()!.listTargets() @@ -76,7 +67,6 @@ export function registerSshTargetCrudHandlers(): void { ipcMain.handle( 'ssh:updateTarget', (_event, args: { id: string; updates: SshTargetUpdateInput }) => { - assertNotRuntimeOwned(args.id, 'edited') return getSshTargetRegistryStore()!.updateTarget( args.id, omitRendererSshTargetGeneration(args.updates) @@ -85,7 +75,6 @@ export function registerSshTargetCrudHandlers(): void { ) ipcMain.handle('ssh:removeTarget', async (_event, args: { id: string }) => { - assertNotRuntimeOwned(args.id, 'removed') await removeRegisteredSshTarget(args.id) }) diff --git a/src/main/ipc/ssh-target-lifecycle-queue.ts b/src/main/ipc/ssh-target-lifecycle-queue.ts index f6c5221aa5c..014a5037175 100644 --- a/src/main/ipc/ssh-target-lifecycle-queue.ts +++ b/src/main/ipc/ssh-target-lifecycle-queue.ts @@ -1,27 +1,25 @@ // Serializes disconnect/remove/terminate/reset for a single SSH target so they cannot interleave. export const targetLifecycleInFlight = new Map>() -export function runTargetLifecycle(targetId: string, operation: () => Promise): Promise { +export function runTargetLifecycle( + targetId: string, + operation: () => Promise +): Promise { const prior = targetLifecycleInFlight.get(targetId) const operationPromise = (async () => { if (prior) { await prior.catch(() => undefined) } - return operation() + await operation() })() let trackedPromise!: Promise - trackedPromise = operationPromise - .then( - () => undefined, - () => undefined - ) - .finally(() => { - if (targetLifecycleInFlight.get(targetId) === trackedPromise) { - targetLifecycleInFlight.delete(targetId) - } - }) + trackedPromise = operationPromise.finally(() => { + if (targetLifecycleInFlight.get(targetId) === trackedPromise) { + targetLifecycleInFlight.delete(targetId) + } + }) targetLifecycleInFlight.set(targetId, trackedPromise) - return operationPromise + return trackedPromise } export async function awaitTargetLifecycle(targetId: string): Promise { diff --git a/src/main/ipc/ssh-test-connection-probe-registry.test.ts b/src/main/ipc/ssh-test-connection-probe-registry.test.ts deleted file mode 100644 index c8b75e1c1ea..00000000000 --- a/src/main/ipc/ssh-test-connection-probe-registry.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { - awaitSshTestConnectionProbes, - credentialRequestedForTarget, - runSshTestConnectionProbe, - testConnectionProbes, - testingTargets -} from './ssh-connect-attempt-registry' - -function deferred() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -it('publishes global and target tracking before callback-capable work', async () => { - const close = deferred() - let joined!: Promise - const finished = vi.fn() - const operation = vi.fn(async () => { - expect(testConnectionProbes.has(probe)).toBe(true) - expect(testingTargets.has('published')).toBe(true) - joined = awaitSshTestConnectionProbes('published').then(finished) - await close.promise - return 'connected' - }) - const probe = runSshTestConnectionProbe('published', operation) - expect(operation).not.toHaveBeenCalled() - expect(testConnectionProbes.has(probe)).toBe(true) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - close.resolve() - await expect(probe).resolves.toBe('connected') - await joined - expect(finished).toHaveBeenCalledTimes(1) - expect(testConnectionProbes.has(probe)).toBe(false) - expect(testingTargets.has('published')).toBe(false) -}) - -it('joins every admitted probe and retains callback suppression until the final settlement', async () => { - const first = deferred() - const second = deferred() - const a = runSshTestConnectionProbe('multiple', () => first.promise) - const b = runSshTestConnectionProbe('multiple', () => second.promise) - credentialRequestedForTarget.add('multiple') - const finished = vi.fn() - const joined = awaitSshTestConnectionProbes('multiple').then(finished) - first.resolve() - await a - expect(testingTargets.has('multiple')).toBe(true) - expect(credentialRequestedForTarget.has('multiple')).toBe(true) - expect(finished).not.toHaveBeenCalled() - second.resolve() - await b - await joined - expect(testingTargets.has('multiple')).toBe(false) - expect(credentialRequestedForTarget.has('multiple')).toBe(false) -}) - -it('joins failed probes without mistaking rejection for unfinished work', async () => { - const failure = new Error('connection failed') - const probe = runSshTestConnectionProbe('failed', async () => { - throw failure - }) - const result = expect(probe).rejects.toBe(failure) - await expect(awaitSshTestConnectionProbes('failed')).resolves.toBeUndefined() - await result - expect(testConnectionProbes.has(probe)).toBe(false) - expect(testingTargets.has('failed')).toBe(false) -}) - -it('does not wait for another target', async () => { - const close = deferred() - const probe = runSshTestConnectionProbe('other', () => close.promise) - await awaitSshTestConnectionProbes('absent') - expect(testConnectionProbes.has(probe)).toBe(true) - close.resolve() - await probe -}) - -it('rechecks the target after a joined snapshot settles', async () => { - const first = deferred() - const second = deferred() - const a = runSshTestConnectionProbe('later', () => first.promise) - const finished = vi.fn() - const joined = awaitSshTestConnectionProbes('later').then(finished) - const b = runSshTestConnectionProbe('later', () => second.promise) - first.resolve() - await a - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - second.resolve() - await b - await joined - expect(finished).toHaveBeenCalledTimes(1) -}) diff --git a/src/main/ipc/ssh-test-probe-presence.test.ts b/src/main/ipc/ssh-test-probe-presence.test.ts deleted file mode 100644 index 73287c4798f..00000000000 --- a/src/main/ipc/ssh-test-probe-presence.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, it } from 'vitest' -import { - hasSshTestConnectionProbes, - runSshTestConnectionProbe, - testingTargets -} from './ssh-connect-attempt-registry' - -it('publishes target-scoped presence before callbacks and retains it until settlement', async () => { - const target = 'probe-presence-success' - const work = Promise.withResolvers() - const probe = runSshTestConnectionProbe(target, async () => { - expect(hasSshTestConnectionProbes(target)).toBe(true) - await work.promise - }) - expect(hasSshTestConnectionProbes(target)).toBe(true) - expect(hasSshTestConnectionProbes('unrelated')).toBe(false) - testingTargets.delete(target) - expect(hasSshTestConnectionProbes(target)).toBe(true) - work.resolve() - await probe - expect(hasSshTestConnectionProbes(target)).toBe(false) -}) - -it('keeps other probes visible after a failed probe settles', async () => { - const target = 'probe-presence-failure' - const work = Promise.withResolvers() - const pending = runSshTestConnectionProbe(target, () => work.promise) - const failed = runSshTestConnectionProbe(target, async () => { - throw new Error('failed') - }) - await expect(failed).rejects.toThrow('failed') - expect(hasSshTestConnectionProbes(target)).toBe(true) - work.resolve() - await pending - expect(hasSshTestConnectionProbes(target)).toBe(false) -}) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 607f16bb354..9ccf4ad16f6 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -77,8 +77,6 @@ import { broadcastPortForwards, relayStateOverrides } from './ssh-renderer-broad import { resetSshShutdownDrain } from './ssh-shutdown-drain' import { registerSshTargetCrudHandlers } from './ssh-target-crud-handlers' import { targetLifecycleInFlight } from './ssh-target-lifecycle-queue' -import { disposeOrcadManagedTunnels } from '../ssh/orcad-managed-tunnel' -import { getAppEnvironment } from '../../shared/app-environment' const SSH_IPC_CHANNELS = [ 'ssh:listTargets', @@ -211,7 +209,7 @@ export function registerSshHandlers( } }) refreshActiveRelaySessions() - registerPowerMonitorReconnect(() => getAppEnvironment().getPath('userData')) + registerPowerMonitorReconnect() registerSshBrowseHandler(() => connectionManager) setSshConnectionManagerResolver(() => connectionManager) @@ -255,7 +253,6 @@ export async function resetSshHandlerStateForTests(): Promise { resetSshShutdownDrain() await connectionManager?.disconnectAll() - disposeOrcadManagedTunnels() portForwardManager?.dispose() setConnectionManager(null) setSshConnectionManagerResolver(null) diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index 8eb025da52f..8d14755ab18 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -5,7 +5,6 @@ import type { ProviderRequestId } from '../../shared/detected-worktree-provider- import { toSshExecutionHostId } from '../../shared/execution-host' import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees/listing/register-detected-worktree-handlers' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { hasSshProviderContinuations } from '../ssh/ssh-provider-continuations' import { getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -96,99 +95,6 @@ describe('registerWorktreeHandlers', () => { setupWorktreeHandlers() }) - it.each(['resolve', 'reject'] as const)( - 'retains canceled SSH provider continuations until the provider settles: %s', - async (outcome) => { - const targetId = `continuation-cancel-${outcome}` - const repo = { - id: `repo-${targetId}`, - path: '/remote/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: targetId - } - let resolveProvider!: (value: GitWorktreeInfo[]) => void - let rejectProvider!: (error: Error) => void - const provider = { - listWorktrees: vi.fn( - () => - new Promise((resolve, reject) => { - resolveProvider = resolve - rejectProvider = reject - }) - ) - } - store.getRepos.mockReturnValue([repo]) - getSshGitProviderMock.mockReturnValue(provider) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: request ids are minted by the renderer; any unique string is a valid id here. - const providerRequestId = `request-${targetId}` as ProviderRequestId - const pending = handlers['worktrees:listDetected'](ipcEvent, { - providerRequestId, - repoId: repo.id, - executionHostId: toSshExecutionHostId(targetId), - expectedAuthority: getSshProviderAuthority(targetId) - }) - await Promise.resolve() - expect(provider.listWorktrees).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(true) - handlers['worktrees:cancelListDetected'](ipcEvent, { providerRequestId }) - await expect(pending).resolves.toMatchObject({ status: 'canceled' }) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(hasSshProviderContinuations(`${targetId}-other`)).toBe(false) - if (outcome === 'resolve') { - resolveProvider([]) - } else { - rejectProvider(new Error('late provider failure')) - } - await vi.waitFor(() => expect(hasSshProviderContinuations(targetId)).toBe(false)) - expect(store.setWorktreeMeta).not.toHaveBeenCalled() - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - } - ) - - it('tracks legacy SSH listing until settlement without tracking a local folder listing', async () => { - const targetId = 'continuation-legacy' - const repo = { - id: 'repo-legacy', - path: '/remote/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: targetId - } - const localRepo: Repo = { - id: 'repo-local', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0, - kind: 'folder' - } - let resolveProvider!: (value: GitWorktreeInfo[]) => void - const provider = { - listWorktrees: vi.fn( - () => - new Promise((resolve) => { - resolveProvider = resolve - }) - ) - } - store.getRepos.mockReturnValue([repo, localRepo]) - getSshGitProviderMock.mockReturnValue(provider) - const pending = handlers['worktrees:listDetected'](ipcEvent, { repoId: repo.id }) - expect(hasSshProviderContinuations(targetId)).toBe(true) - await expect( - handlers['worktrees:listDetected'](ipcEvent, { repoId: localRepo.id }) - ).resolves.toMatchObject({ repoId: localRepo.id, authoritative: true }) - expect(hasSshProviderContinuations(localRepo.id)).toBe(false) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(provider.listWorktrees).toHaveBeenCalledOnce() - resolveProvider([]) - await expect(pending).resolves.toMatchObject({ repoId: repo.id, authoritative: true }) - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - it.each([ ['malformed', 'ssh:%'], ['contradictory', toSshExecutionHostId('target-b')] diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts index de466ff519e..442a70985c8 100644 --- a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -1,5 +1,4 @@ import { ipcMain } from 'electron' -import { runSshProviderContinuation } from '../../../ssh/ssh-provider-continuations' import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types' import type { HostQualifiedDetectedWorktreeResult, @@ -73,21 +72,16 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) : undefined try { - const list = () => - listHostQualifiedDetectedWorktrees( - store, - args, - controller - ? { - signal: controller.signal, - status: () => (timedOut ? 'timed-out' : 'canceled') - } - : undefined - ) - const providerResult = - parsedHost?.kind === 'ssh' - ? runSshProviderContinuation(parsedHost.targetId, list) - : list() + const providerResult = listHostQualifiedDetectedWorktrees( + store, + args, + controller + ? { + signal: controller.signal, + status: () => (timedOut ? 'timed-out' : 'canceled') + } + : undefined + ) return abortedResult ? await Promise.race([providerResult, abortedResult]) : await providerResult @@ -112,19 +106,17 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v const connectionId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) const provider = connectionId ? getSshGitProvider(connectionId) : undefined const authority = connectionId ? { ...getSshProviderAuthority(connectionId) } : undefined - const list = () => - listDetectedWorktreesForCapturedRepo( - store, - repo, - () => - isCapturedRepoCurrent(store, repo) && - (!connectionId || - (getSshGitProvider(connectionId) === provider && - authority !== undefined && - isCurrentSshProviderAuthority(authority))), - provider - ) - const result = await (connectionId ? runSshProviderContinuation(connectionId, list) : list()) + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + () => + isCapturedRepoCurrent(store, repo) && + (!connectionId || + (getSshGitProvider(connectionId) === provider && + authority !== undefined && + isCurrentSshProviderAuthority(authority))), + provider + ) return result && !('providerAbortStatus' in result) ? result : { diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index d5e09d2f8f2..d91743b0185 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -1,10 +1,7 @@ import { ipcMain } from 'electron' import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' -import { - getRepoExecutionHostId, - getSshTargetIdForExecutionHost -} from '../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { withWorktreeSpan } from '../../../observability/instrumentation' import { parseWorktreeId } from '../../worktree-logic' import type { RemoveWorktreeArgs } from '../ipc-context-schemas' @@ -20,7 +17,6 @@ import { waitForPendingWorktreeRemoval } from '../../../worktree-background-removal' import { runSerializedWorktreeRemovalAcceptance } from '../../../worktree-removal-acceptance-queue' -import { runSshProviderContinuation } from '../../../ssh/ssh-provider-continuations' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -40,7 +36,6 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo if (pending) { return { ...(await pending), catalogVersion: getLocalWorktreeCatalogVersion(repoId) } } - const targetId = getSshTargetIdForExecutionHost(removalHostId) ?? repo.connectionId const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) const optionsKey = getWorktreeRemovalOptionsKey(args) const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) @@ -53,15 +48,11 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - const execute = () => - executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) const accept = async (): Promise => // Why: another client's removal of this worktree may have been accepted during the wait. waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) ? { removing: true } - : targetId - ? runSshProviderContinuation(targetId, execute) - : execute() + : executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) const accepted = await (repo.connectionId ? accept() : runSerializedWorktreeRemovalAcceptance(repo.path, accept)) diff --git a/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts b/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts deleted file mode 100644 index a57bd68d594..00000000000 --- a/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { hasSshProviderContinuations } from '../../../ssh/ssh-provider-continuations' -import type { WorktreeIpcContext } from '../worktree-ipc-context' -import { registerWorktreeRemovalHandlers } from './register-worktree-removal-handlers' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - execute: vi.fn(), - resolveRepo: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../../../observability/instrumentation', () => ({ - withWorktreeSpan: (_options: unknown, operation: () => unknown) => operation() -})) -vi.mock('../../worktree-logic', () => ({ - parseWorktreeId: () => ({ repoId: 'repo', worktreePath: '/work/feature' }) -})) -vi.mock('../repo-host-ownership', () => ({ resolveRepoForExecutionHost: mocks.resolveRepo })) -vi.mock('./execute-worktree-removal', () => ({ executeWorktreeRemoval: mocks.execute })) - -beforeEach(() => vi.clearAllMocks()) - -function setup(connectionId?: string, kind = 'git') { - mocks.resolveRepo.mockReturnValue({ id: 'repo', connectionId, kind }) - const worktreeRemovalsInFlight = new Map() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removal reads only store and worktreeRemovalsInFlight from the context. - registerWorktreeRemovalHandlers({ - store: {}, - worktreeRemovalsInFlight - } as unknown as WorktreeIpcContext) - const handler = mocks.handle.mock.calls[0]![1] - return { - remove: (): Promise => handler({}, { worktreeId: 'repo:/work/feature' }), - worktreeRemovalsInFlight - } -} - -it.each(['git', 'folder'])( - 'tracks the full SSH %s removal before invocation and through cleanup', - async (kind) => { - const state = setup('target', kind) - const removed = Promise.withResolvers() - const cleaned = Promise.withResolvers() - mocks.execute.mockImplementation(async () => { - expect(hasSshProviderContinuations('target')).toBe(true) - await removed.promise - await cleaned.promise - return { success: true } - }) - const first = state.remove() - const joined = state.remove() - expect(mocks.execute).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations('elsewhere')).toBe(false) - removed.resolve() - await Promise.resolve() - expect(hasSshProviderContinuations('target')).toBe(true) - cleaned.resolve() - await Promise.all([first, joined]) - expect(hasSshProviderContinuations('target')).toBe(false) - expect(state.worktreeRemovalsInFlight.size).toBe(0) - } -) - -it('retains a failed removal until its underlying operation rejects', async () => { - const state = setup('target') - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - expect(hasSshProviderContinuations('target')).toBe(true) - pending.reject(new Error('cleanup failed')) - await expect(result).rejects.toThrow('cleanup failed') - expect(hasSshProviderContinuations('target')).toBe(false) - expect(state.worktreeRemovalsInFlight.size).toBe(0) -}) - -it('does not register local removal as SSH work', async () => { - const state = setup() - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - await vi.waitFor(() => expect(mocks.execute).toHaveBeenCalledOnce()) - expect(hasSshProviderContinuations('target')).toBe(false) - pending.resolve({}) - await result -}) - -it('tracks a folder owner expressed only by its unified execution host', async () => { - const state = setup() - mocks.resolveRepo.mockReturnValue({ id: 'repo', kind: 'folder', executionHostId: 'ssh:target' }) - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - // Local acceptance is serialized per repo path first, so the continuation starts after a turn. - await vi.waitFor(() => expect(hasSshProviderContinuations('target')).toBe(true)) - pending.resolve({}) - await result - expect(hasSshProviderContinuations('target')).toBe(false) -}) diff --git a/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts b/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts deleted file mode 100644 index c67e4772bee..00000000000 --- a/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { SessionInfo } from '../daemon/types' -import type { Store } from '../persistence' - -const getDaemonProviderMock = vi.fn() -vi.mock('../daemon/daemon-init', () => ({ - getDaemonProvider: () => getDaemonProviderMock() -})) -vi.mock('../project-runtime-git-options', () => ({ - getLocalProjectWorktreeGitOptions: () => ({}) -})) -const listLocalRepoWorktreesStrictMock = vi.fn() -vi.mock('../repo-worktrees', () => ({ - listLocalRepoWorktreesStrict: (...args: unknown[]) => listLocalRepoWorktreesStrictMock(...args) -})) - -function makeStore(folderWorkspaces: FolderWorkspace[]): Store { - const store: Partial = { - getRepos: () => [], - getFolderWorkspaces: () => folderWorkspaces, - getProjectGroups: () => [], - getAllWorktreeMeta: () => ({}), - getAllWorktreeMetaForHost: () => ({}) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: hydration reads only the store members stubbed here. - return store as Store -} - -// Why fresh modules: the hydrator memoizes its pass and the registry is module-scoped. -async function loadFresh() { - vi.resetModules() - const hydrateMod = await import('./hydrate-local-pty-registry') - const registryMod = await import('./pty-registry') - return { - hydrate: hydrateMod.hydrateLocalPtyRegistryAtBoot, - listRegisteredPtys: registryMod.listRegisteredPtys - } -} - -beforeEach(() => { - getDaemonProviderMock.mockReset() - listLocalRepoWorktreesStrictMock.mockReset() -}) - -it('hydrates surviving true folder workspace PTYs without enumerating Git', async () => { - const { hydrate, listRegisteredPtys } = await loadFresh() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership reads only id and executionHostId. - const workspace = { id: 'folder-workspace-1', executionHostId: 'local' } as FolderWorkspace - const session = { sessionId: 'folder:folder-workspace-1@@cafebabe', pid: 4242 } - getDaemonProviderMock.mockReturnValue({ listSessions: vi.fn().mockResolvedValue([session]) }) - - await hydrate(makeStore([workspace])) - - expect(listRegisteredPtys()).toEqual([ - expect.objectContaining({ - ptyId: 'folder:folder-workspace-1@@cafebabe', - worktreeId: 'folder:folder-workspace-1', - pid: 4242 - }) - ]) - expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() -}) - -it.each(['deleted', 'remote', 'ssh'] as const)( - 'rechecks folder ownership after inventory when the catalog becomes %s', - async (change) => { - const { hydrate, listRegisteredPtys } = await loadFresh() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership reads only id and executionHostId. - const folders = [{ id: 'folder-1', executionHostId: 'local' } as FolderWorkspace] - getDaemonProviderMock.mockReturnValue({ - listSessions: vi.fn().mockImplementation(async (): Promise[]> => { - if (change === 'deleted') { - folders.splice(0) - } else { - folders[0].executionHostId = change === 'ssh' ? 'ssh:target-1' : 'runtime:environment-1' - } - return [{ sessionId: 'folder:folder-1@@cafebabe', pid: 4242 }] - }) - }) - - await hydrate(makeStore(folders)) - - expect(listRegisteredPtys()).toEqual([]) - expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() - } -) diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index ffa2f703e03..b7dd5e1d233 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -60,8 +60,7 @@ function makeStore( kind?: Repo['kind'] path?: string }[] = [], - worktreeMeta: Record = {}, - folderWorkspaces: FolderWorkspace[] = [] + worktreeMeta: Record = {} ): Store { const built: Repo[] = repos.map((r) => ({ id: r.id, @@ -73,18 +72,15 @@ function makeStore( executionHostId: r.executionHostId ?? null, kind: r.kind })) - const store: Partial = { + return { getRepos: () => built, - getFolderWorkspaces: () => folderWorkspaces, - getProjectGroups: () => [], + getFolderWorkspaces: (): FolderWorkspace[] => [], getAllWorktreeMeta: () => worktreeMeta, getAllWorktreeMetaForHost: (hostId) => Object.fromEntries( Object.entries(worktreeMeta).filter(([, meta]) => !meta.hostId || meta.hostId === hostId) ) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: hydration reads only the store members stubbed above. - return store as Store + } as Store } function makeProvider(sessions: SessionInfo[]): Pick { diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index 6c70dc4c0e6..4e5063b5fc1 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -2,6 +2,7 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/ex import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import { parsePtySessionId } from '../../shared/pty-session-id-format' +import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/id' @@ -16,7 +17,6 @@ import { readAllWorktreeMetaForHost } from '../persistence/host-qualified-worktr import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { listLocalRepoWorktreesStrict } from '../repo-worktrees' import { listRegisteredPtys, registerPty } from './pty-registry' -import { getVerifiedLocalFolderWorkspaceKeys } from './verified-local-folder-workspaces' type HydrationStore = Store @@ -165,11 +165,6 @@ async function hydrateLocalPtyRegistry( } throwIfSignalAborted(signal) - const verifiedLocalFolderKeys = getVerifiedLocalFolderWorkspaceKeys({ - folderWorkspaces: store.getFolderWorkspaces(), - projectGroups: store.getProjectGroups(), - repos: store.getRepos() - }) for (const info of inventory.sessions) { throwIfSignalAborted(signal) if (alreadyRegistered.has(info.sessionId)) { @@ -191,7 +186,7 @@ async function hydrateLocalPtyRegistry( return complete function isVerifiedLocalWorktree(worktreeId: string): boolean { - if (verifiedLocalFolderKeys.has(worktreeId) || verifiedFolderWorktreeIds.has(worktreeId)) { + if (verifiedFolderWorktreeIds.has(worktreeId)) { return true } const key = worktreeIdComparisonKey(worktreeId) @@ -225,6 +220,17 @@ function getVerifiedFolderWorktreeIds( repoCatalog: LocalRepoCatalog ): Set { const verified = new Set() + const folders = store.getFolderWorkspaces() + const counts = new Map() + for (const folder of folders) { + counts.set(folder.id, (counts.get(folder.id) ?? 0) + 1) + } + for (const folder of folders) { + const worktree = folderWorkspaceToWorktree(folder) + if (counts.get(folder.id) === 1 && worktree.hostId === LOCAL_EXECUTION_HOST_ID) { + verified.add(worktree.id) + } + } const metadata = readAllWorktreeMetaForHost(store, LOCAL_EXECUTION_HOST_ID) for (const [worktreeId, meta] of Object.entries(metadata)) { const parsed = splitWorktreeId(worktreeId) diff --git a/src/main/memory/verified-local-folder-workspaces.test.ts b/src/main/memory/verified-local-folder-workspaces.test.ts deleted file mode 100644 index 594e701561b..00000000000 --- a/src/main/memory/verified-local-folder-workspaces.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { FolderWorkspaceHostState } from '../../shared/folder-workspace-execution-host' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../shared/project-group-types' -import type { Repo } from '../../shared/repo-types' -import { getVerifiedLocalFolderWorkspaceKeys } from './verified-local-folder-workspaces' - -const folder = (patch: Partial = {}): FolderWorkspace => ({ - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Workspace', - folderPath: '/workspace', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - createdAt: 0, - lastActivityAt: 0, - updatedAt: 0, - ...patch -}) -const repo = (patch: Partial = {}): Repo => ({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0, - ...patch -}) -const projectGroup = (patch: Partial = {}): ProjectGroup => ({ - id: 'group-1', - name: 'Group', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...patch -}) -const state = (patch: Partial = {}): FolderWorkspaceHostState => ({ - folderWorkspaces: [folder()], - projectGroups: [], - repos: [], - ...patch -}) - -describe('verified local folder workspace keys', () => { - it.each([undefined, null, 'local'] as const)('accepts local ownership %s', (executionHostId) => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [folder({ executionHostId })] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) - - it.each(['ssh:target-1', 'runtime:environment-1', 'invalid', ''])( - 'rejects nonlocal or invalid ownership %s', - (executionHostId) => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [ - // Why Object.assign: invalid stored ids must reach the parser without widening the type. - Object.assign(folder(), { executionHostId }) - ] - }) - ) - ).toEqual(new Set()) - } - ) - - it('honors explicit local ownership over legacy scope', () => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [folder({ executionHostId: 'local', connectionId: 'target-1' })], - repos: [repo({ executionHostId: 'runtime:environment-1' })] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) - - it.each([ - state({ folderWorkspaces: [] }), - state({ folderWorkspaces: [folder(), folder()] }), - state({ - folderWorkspaces: [ - folder({ executionHostId: 'local' }), - folder({ executionHostId: 'runtime:environment-1' }) - ] - }), - state({ folderWorkspaces: [folder({ connectionId: 'target-1' })] }), - state({ projectGroups: [projectGroup({ connectionId: 'target-1' })] }), - state({ repos: [repo({ executionHostId: 'runtime:environment-1' })] }), - state({ repos: [repo({ executionHostId: 'ssh:target-1' })] }), - state({ repos: [repo(), repo({ id: 'repo-2', connectionId: 'target-1' })] }), - state({ repos: [repo(), repo({ id: 'repo-2', executionHostId: 'runtime:environment-1' })] }) - ])('rejects missing, duplicate, remote, or ambiguous scope %#', (catalog) => { - expect(getVerifiedLocalFolderWorkspaceKeys(catalog)).toEqual(new Set()) - }) - - it('accepts local inferred scope without unrelated runtime repos affecting it', () => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - repos: [ - repo(), - repo({ id: 'remote', path: '/elsewhere', executionHostId: 'runtime:environment-1' }) - ] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) -}) diff --git a/src/main/memory/verified-local-folder-workspaces.ts b/src/main/memory/verified-local-folder-workspaces.ts deleted file mode 100644 index eae3ee39967..00000000000 --- a/src/main/memory/verified-local-folder-workspaces.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' -import { - findFolderWorkspaceCandidateRepos, - resolveFolderWorkspaceHost, - type FolderWorkspaceHostState -} from '../../shared/folder-workspace-execution-host' -import { folderWorkspaceKey } from '../../shared/workspace-scope' - -export function getVerifiedLocalFolderWorkspaceKeys(state: FolderWorkspaceHostState): Set { - const counts = new Map() - for (const workspace of state.folderWorkspaces) { - counts.set(workspace.id, (counts.get(workspace.id) ?? 0) + 1) - } - const keys = new Set() - for (const workspace of state.folderWorkspaces) { - if (counts.get(workspace.id) !== 1) { - continue - } - const pin = parseExecutionHostId(workspace.executionHostId) - if (workspace.executionHostId != null) { - if (pin?.kind === 'local') { - keys.add(folderWorkspaceKey(workspace.id)) - } - continue - } - if (resolveFolderWorkspaceHost(state, workspace.id).kind !== 'local') { - continue - } - // The shared legacy resolver projects runtime ownership as local; attribution cannot. - if ( - findFolderWorkspaceCandidateRepos(state, workspace.id).some( - (repo) => getRepoExecutionHostId(repo) !== 'local' - ) - ) { - continue - } - keys.add(folderWorkspaceKey(workspace.id)) - } - return keys -} diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts index ce509767119..daedc20a111 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts @@ -66,120 +66,6 @@ describe('client-hosted rows in the repo-removal and transfer paths', () => { expect(result.clientHostedBrowserPagesByWorktree).toBeUndefined() }) - it('rekeys markdown frontmatter visibility with the open file identity', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-a/README.md': false }) - }) - - it('omits stale markdown visibility entries that no longer name an open file', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false, - '/tmp/worktree-a/closed.md': false - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-a/README.md': false }) - }) - - it('prunes markdown visibility for files in a removed repo', () => { - const session = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ], - [RETAINED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-b/README.md', - relativePath: 'README.md', - worktreeId: RETAINED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false, - '/tmp/worktree-b/README.md': false - } - } - - const result = removeRepoFromWorkspaceSession(session, REMOVED_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-b/README.md': false }) - }) - - it('keeps a unified-only terminal layout attached during transfer', () => { - const session = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: {}, - unifiedTabs: { - [REMOVED_WORKTREE_ID]: [ - { - id: 'terminal-tab-1', - entityId: 'terminal-tab-1', - groupId: 'group-1', - worktreeId: REMOVED_WORKTREE_ID, - contentType: 'terminal' as const, - label: 'Terminal', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'terminal-tab-1': { - root: { type: 'leaf' as const, leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null, - titlesByLeafId: { 'leaf-1': 'shell' } - } - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.terminalLayoutsByTabId).toEqual(session.terminalLayoutsByTabId) - }) - it('removes the transferred repo rows from the source it left', () => { const source = removeRepoFromWorkspaceSession( sessionWithClientHostedRows(), diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 219ef63d8ca..02a7ce77ca6 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -66,10 +66,7 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { }, openFilesByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByBespokeRule' }, activeFileIdByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByOwnerKey' }, - markdownFrontmatterVisible: { - onRepoRemoval: 'prunedByBespokeRule', - onTransfer: 'copiedByBespokeRule' - }, + markdownFrontmatterVisible: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, browserTabsByWorktree: { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index d17c3b24302..0c1ea243b3e 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -3,7 +3,6 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from './profile-project-session-field-disposition' -import { markdownFileIdCandidates } from './profile-session-markdown-transfer' import { isRepoWorktreeId, ownerKeyBelongsToRepo, @@ -59,10 +58,6 @@ export function mergeWorkspaceSessions( } : {}), openFilesByWorktree: { ...base.openFilesByWorktree, ...incoming.openFilesByWorktree }, - markdownFrontmatterVisible: { - ...base.markdownFrontmatterVisible, - ...incoming.markdownFrontmatterVisible - }, browserTabsByWorktree: { ...base.browserTabsByWorktree, ...incoming.browserTabsByWorktree @@ -138,17 +133,6 @@ export function removeRepoFromWorkspaceSession( ): WorkspaceSessionState { const next = structuredClone(session ?? getDefaultWorkspaceSession()) const removedTerminalTabIds = new Set() - const removedMarkdownFileIds = new Set() - for (const [ownerKey, files] of Object.entries(next.openFilesByWorktree ?? {})) { - if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { - continue - } - for (const file of files) { - markdownFileIdCandidates(file.filePath, ownerKey, file.runtimeEnvironmentId).forEach((id) => - removedMarkdownFileIds.add(id) - ) - } - } for (const [ownerKey, tabs] of Object.entries(next.tabsByWorktree)) { if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { continue @@ -178,13 +162,6 @@ export function removeRepoFromWorkspaceSession( const record = next[field] as Record | undefined ;(next as Record)[field] = removeRepoWorktreeRecord(record, repoId) } - if (next.markdownFrontmatterVisible) { - next.markdownFrontmatterVisible = Object.fromEntries( - Object.entries(next.markdownFrontmatterVisible).filter( - ([fileId]) => !removedMarkdownFileIds.has(fileId) - ) - ) - } if (next.terminalSurfaceTombstonesByPaneKey) { next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index 39c3b3b6189..3018f07173c 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -1,10 +1,24 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' import type { ExecutionHostId } from '../../shared/execution-host' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { rekeyOwnerKey, rekeyWorktreeId } from './profile-project-worktree-identity' +import type { + BrowserPage, + BrowserPageDocLocation, + BrowserWorkspace +} from '../../shared/browser-workspace-types' +import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' +import type { Tab, TabGroup } from '../../shared/tab-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { SESSION_FIELDS_COPIED_BY_OWNER_KEY } from './profile-project-session-field-disposition' import { - extractSessionOwnersForTransfer, - hasTransferredSessionState -} from './profile-session-owner-transfer' + isRepoWorktreeId, + rekeyOwnerKey, + rekeyWorktreeId +} from './profile-project-worktree-identity' export function extractHostSessionsForTransfer( sessions: Partial> | undefined, @@ -24,13 +38,210 @@ export function extractHostSessionsForTransfer( return next } +function hasTransferredSessionState(session: WorkspaceSessionState): boolean { + return ( + Object.keys(session.tabsByWorktree).length > 0 || + Object.keys(session.openFilesByWorktree ?? {}).length > 0 || + Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || + Object.keys(session.unifiedTabs ?? {}).length > 0 || + Object.keys(session.tabGroups ?? {}).length > 0 || + Object.keys(session.lastVisitedAtByWorktreeId ?? {}).length > 0 || + Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0 + ) +} + export function extractSessionForTransfer( session: WorkspaceSessionState | undefined, oldRepoId: string, newRepoId: string ): WorkspaceSessionState { - return extractSessionOwnersForTransfer(session, { - mapOwnerKey: (ownerKey) => rekeyOwnerKey(oldRepoId, newRepoId, ownerKey), - mapWorktreeId: (worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId) - }) + const source = session ?? getDefaultWorkspaceSession() + const transferred = getDefaultWorkspaceSession() + const copiedTerminalTabIds = new Set() + const copiedBrowserWorkspaceIds = new Set() + const mapOwnerRecord = ( + record: Record | undefined, + mapValue: (value: T) => T + ): Record => { + const next: Record = {} + for (const [ownerKey, value] of Object.entries(record ?? {})) { + const nextOwnerKey = rekeyOwnerKey(oldRepoId, newRepoId, ownerKey) + if (nextOwnerKey) { + next[nextOwnerKey] = mapValue(value) + } + } + return next + } + transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedTerminalTabIds.add(tab.id) + return rekeyTerminalTab(tab, oldRepoId, newRepoId) + }) + ) + transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) => + files.map((file) => rekeyOpenFile(file, oldRepoId, newRepoId)) + ) + transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedBrowserWorkspaceIds.add(tab.id) + return rekeyBrowserWorkspace(tab, oldRepoId, newRepoId) + }) + ) + transferred.browserPagesByWorkspace = copyBrowserPages( + source.browserPagesByWorkspace, + copiedBrowserWorkspaceIds, + oldRepoId, + newRepoId + ) + // Driven by the census so a field cannot be added to the session type and forgotten here. The + // census is also where a field's deliberate non-transfer is recorded -- notably the runtime's + // client-hosted rows, which name a paired device this payload does not carry. + for (const field of SESSION_FIELDS_COPIED_BY_OWNER_KEY) { + const record = source[field] as Record | undefined + ;(transferred as Record)[field] = mapOwnerRecord(record, (value) => + structuredClone(value) + ) + } + transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) => + tabs.map((tab) => rekeyUnifiedTab(tab, oldRepoId, newRepoId)) + ) + transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) => + groups.map((group) => rekeyTabGroup(group, oldRepoId, newRepoId)) + ) + transferred.terminalLayoutsByTabId = {} + for (const tabId of copiedTerminalTabIds) { + const layout = source.terminalLayoutsByTabId[tabId] + if (layout) { + transferred.terminalLayoutsByTabId[tabId] = structuredClone(layout) + } + } + if (source.localOnlyScrollbackByTabId) { + transferred.localOnlyScrollbackByTabId = Object.fromEntries( + Object.entries(source.localOnlyScrollbackByTabId) + .filter(([tabId]) => copiedTerminalTabIds.has(tabId)) + .map(([tabId, buffers]) => [tabId, structuredClone(buffers)]) + ) + } + transferred.terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(source.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator)) + }) + ) + transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( + Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap( + ([paneKey, tombstone]) => + isRepoWorktreeId(oldRepoId, tombstone.worktreeId) + ? [ + [ + paneKey, + { + ...structuredClone(tombstone), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tombstone.worktreeId) + } + ] as const + ] + : [] + ) + ) + transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown + ?.filter((worktreeId) => isRepoWorktreeId(oldRepoId, worktreeId)) + .map((worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId)) + if (source.activeWorktreeId && isRepoWorktreeId(oldRepoId, source.activeWorktreeId)) { + transferred.activeWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, source.activeWorktreeId) + } + const activeScope = source.activeWorkspaceKey + ? parseWorkspaceKey(source.activeWorkspaceKey) + : null + if (activeScope?.type === 'worktree' && isRepoWorktreeId(oldRepoId, activeScope.worktreeId)) { + transferred.activeWorkspaceKey = worktreeWorkspaceKey( + rekeyWorktreeId(oldRepoId, newRepoId, activeScope.worktreeId) + ) + } + return transferred +} + +function rekeyTerminalTab(tab: TerminalTab, oldRepoId: string, newRepoId: string): TerminalTab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyOpenFile( + file: PersistedOpenFile, + oldRepoId: string, + newRepoId: string +): PersistedOpenFile { + return { + ...structuredClone(file), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, file.worktreeId) + } +} + +function rekeyBrowserWorkspace( + workspace: BrowserWorkspace, + oldRepoId: string, + newRepoId: string +): BrowserWorkspace { + return { + ...structuredClone(workspace), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId), + ...(workspace.docLocation + ? { docLocation: rekeyBrowserDocLocation(workspace.docLocation, oldRepoId, newRepoId) } + : {}), + // Why: both the session profile and the resolved partition string are + // source-profile-scoped; carrying either across would point the restored + // pane at a partition the target profile's allowlist rejects. + sessionProfileId: null, + sessionPartition: null + } +} + +function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage { + return { + ...structuredClone(page), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId), + ...(page.docLocation + ? { docLocation: rekeyBrowserDocLocation(page.docLocation, oldRepoId, newRepoId) } + : {}) + } +} + +function rekeyBrowserDocLocation( + location: BrowserPageDocLocation, + oldRepoId: string, + newRepoId: string +): BrowserPageDocLocation { + const nextWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, location.worktreeId) + return remapBrowserPageDocLocation(location, location.worktreeId, nextWorktreeId) +} + +function copyBrowserPages( + pagesByWorkspace: Record | undefined, + workspaceIds: ReadonlySet, + oldRepoId: string, + newRepoId: string +): Record { + const next: Record = {} + for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) { + if (workspaceIds.has(workspaceId)) { + next[workspaceId] = pages.map((page) => rekeyBrowserPage(page, oldRepoId, newRepoId)) + } + } + return next +} + +function rekeyUnifiedTab(tab: Tab, oldRepoId: string, newRepoId: string): Tab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyTabGroup(group: TabGroup, oldRepoId: string, newRepoId: string): TabGroup { + return { + ...structuredClone(group), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, group.worktreeId) + } } diff --git a/src/main/orca-profiles/profile-session-markdown-transfer.ts b/src/main/orca-profiles/profile-session-markdown-transfer.ts deleted file mode 100644 index c9edf77a9c5..00000000000 --- a/src/main/orca-profiles/profile-session-markdown-transfer.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -type OwnerProjection = { - mapOwnerKey: (ownerKey: string) => string | null - mapWorktreeId: (worktreeId: string) => string -} - -/** Map file-keyed markdown visibility along with the persisted open-file identity it belongs to. */ -export function mapMarkdownFrontmatterVisible( - visibleByFileId: Record | undefined, - filesByOwner: WorkspaceSessionState['openFilesByWorktree'] | undefined, - projection: OwnerProjection -): Record { - const next: Record = {} - const sourceToDestination = buildMarkdownFrontmatterIdMap(filesByOwner, projection) - for (const [sourceFileId, visible] of Object.entries(visibleByFileId ?? {})) { - if (visible) { - // Visible is the hydration default; preserving only hidden overrides avoids carrying stale - // positive entries while retaining the exact rendered result. - continue - } - const destinationId = sourceToDestination.get(sourceFileId) - if (destinationId) { - next[destinationId] = false - } - } - return next -} - -/** - * Builds the file-id mapping used by hydration, marking a source id null when two transferred - * files claim it. An ambiguous visibility override is never guessed at by the migration. - */ -export function buildMarkdownFrontmatterIdMap( - filesByOwner: WorkspaceSessionState['openFilesByWorktree'] | undefined, - projection: OwnerProjection -): ReadonlyMap { - const result = new Map() - for (const [sourceOwnerKey, files] of Object.entries(filesByOwner ?? {})) { - if (!projection.mapOwnerKey(sourceOwnerKey)) { - continue - } - const destinationWorktreeId = projection.mapWorktreeId(sourceOwnerKey) - const sourceWorktreeIds = [sourceOwnerKey] - const separator = sourceOwnerKey.indexOf('|') - if (separator > 0) { - sourceWorktreeIds.push(sourceOwnerKey.slice(separator + 1)) - } - for (const file of files) { - const destinationId = ownedEditorFileId( - file.filePath, - destinationWorktreeId, - file.runtimeEnvironmentId - ) - for (const sourceWorktreeId of sourceWorktreeIds) { - const candidates = markdownFileIdCandidates( - file.filePath, - sourceWorktreeId, - file.runtimeEnvironmentId - ) - for (const sourceId of candidates) { - const mapped = sourceId === file.filePath ? file.filePath : destinationId - const existing = result.get(sourceId) - result.set(sourceId, existing === undefined || existing === mapped ? mapped : null) - } - } - } - } - return result -} - -/** Returns the IDs hydration can assign to a persisted file before any runtime data is loaded. */ -export function markdownFileIdCandidates( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): ReadonlySet { - return new Set([filePath, ownedEditorFileId(filePath, worktreeId, runtimeEnvironmentId)]) -} - -function ownedEditorFileId( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): string { - const runtimeKey = runtimeEnvironmentId?.trim() || 'local' - return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` -} diff --git a/src/main/orca-profiles/profile-session-owner-transfer.test.ts b/src/main/orca-profiles/profile-session-owner-transfer.test.ts deleted file mode 100644 index ebcbb472032..00000000000 --- a/src/main/orca-profiles/profile-session-owner-transfer.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { PersistedOpenFile } from '../../shared/workspace-session-state-types' -import { mergeWorkspaceSessions } from './profile-project-session-state' -import { extractSessionForTransfer } from './profile-project-session-transfer' -import { buildMarkdownFrontmatterIdMap } from './profile-session-markdown-transfer' -import { extractSessionOwnersForTransfer } from './profile-session-owner-transfer' - -const SOURCE = 'repo-1::/tmp/a' -const DESTINATION = 'repo-9::/tmp/a' -const editorId = (worktreeId: string, filePath: string, runtime = 'local') => - `editor:${encodeURIComponent(worktreeId)}:${runtime}:${encodeURIComponent(filePath)}` - -function file(filePath: string, worktreeId = SOURCE): PersistedOpenFile { - return { filePath, relativePath: filePath.split('/').at(-1)!, worktreeId, language: 'markdown' } -} - -describe('session owner transfer', () => { - it('moves a hidden override keyed by the owned editor id to the destination worktree', () => { - const session = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { [SOURCE]: [file('/tmp/a/README.md')] }, - markdownFrontmatterVisible: { - [editorId(SOURCE, '/tmp/a/README.md')]: false, - // Visible is the hydration default, so it is not carried. - '/tmp/a/README.md': true - } - } - - const result = extractSessionForTransfer(session, 'repo-1', 'repo-9') - - expect(result.markdownFrontmatterVisible).toEqual({ - [editorId(DESTINATION, '/tmp/a/README.md')]: false - }) - }) - - it('refuses to guess an override two transferred files both claim', () => { - // Two host-qualified owners of one worktree each claim its unqualified editor id. - const owners = { [`h1|${SOURCE}`]: 'repo-9::/tmp/h1', [`h2|${SOURCE}`]: 'repo-9::/tmp/h2' } - const projection = { - mapOwnerKey: (key: string) => owners[key] ?? null, - mapWorktreeId: (key: string) => owners[key] ?? key - } - const files = { - [`h1|${SOURCE}`]: [file('/tmp/a/x.md')], - [`h2|${SOURCE}`]: [file('/tmp/a/x.md')] - } - - expect( - buildMarkdownFrontmatterIdMap(files, projection).get(editorId(SOURCE, '/tmp/a/x.md')) - ).toBe(null) - const visibility = extractSessionOwnersForTransfer( - { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: files, - markdownFrontmatterVisible: { [editorId(SOURCE, '/tmp/a/x.md')]: false } - }, - projection - ).markdownFrontmatterVisible - - expect(visibility).toEqual({}) - }) - - it('carries a worktree focus key and lets the destination merge keep its own overrides', () => { - const transferred = extractSessionForTransfer( - { - ...getDefaultWorkspaceSession(), - activeWorkspaceKey: `worktree:${SOURCE}`, - openFilesByWorktree: { [SOURCE]: [file('/tmp/a/README.md')] }, - markdownFrontmatterVisible: { '/tmp/a/README.md': false } - }, - 'repo-1', - 'repo-9' - ) - expect(transferred.activeWorkspaceKey).toBe(`worktree:${DESTINATION}`) - - const merged = mergeWorkspaceSessions( - { ...getDefaultWorkspaceSession(), markdownFrontmatterVisible: { '/other.md': false } }, - transferred - ) - - expect(merged.markdownFrontmatterVisible).toEqual({ - '/other.md': false, - '/tmp/a/README.md': false - }) - }) -}) diff --git a/src/main/orca-profiles/profile-session-owner-transfer.ts b/src/main/orca-profiles/profile-session-owner-transfer.ts deleted file mode 100644 index ada6d099c79..00000000000 --- a/src/main/orca-profiles/profile-session-owner-transfer.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' -import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types' -import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' -import type { Tab, TabGroup } from '../../shared/tab-types' -import type { TerminalTab } from '../../shared/terminal-tab-types' -import type { - PersistedOpenFile, - WorkspaceSessionState -} from '../../shared/workspace-session-state-types' -import { isWorkspaceKey } from '../../shared/workspace-scope' -import { SESSION_FIELDS_COPIED_BY_OWNER_KEY } from './profile-project-session-field-disposition' -import { mapMarkdownFrontmatterVisible } from './profile-session-markdown-transfer' - -export { - buildMarkdownFrontmatterIdMap, - markdownFileIdCandidates -} from './profile-session-markdown-transfer' - -export type SessionOwnerProjection = { - mapOwnerKey: (ownerKey: string) => string | null - mapWorktreeId: (worktreeId: string) => string - /** Keeps a sleeping agent's resume record when it can still resume after the transfer. */ - projectSleepingAgentSession?: ( - record: SleepingAgentSessionRecord - ) => SleepingAgentSessionRecord | null - /** Projects source-partition focus scalars when the selected entities are dormant. */ - projectSessionFocus?: (args: { - source: WorkspaceSessionState - transferred: WorkspaceSessionState - terminalTabIds: ReadonlySet - }) => void -} - -export function extractSessionOwnersForTransfer( - session: WorkspaceSessionState | undefined, - projection: SessionOwnerProjection -): WorkspaceSessionState { - const source = session ?? getDefaultWorkspaceSession() - const transferred = getDefaultWorkspaceSession() - const terminalTabIds = new Set() - const browserWorkspaceIds = new Set() - const mapOwnerRecord = ( - record: Record | undefined, - mapValue: (value: T) => T - ): Record => { - const next: Record = {} - for (const [ownerKey, value] of Object.entries(record ?? {})) { - const nextOwnerKey = projection.mapOwnerKey(ownerKey) - if (nextOwnerKey) { - next[nextOwnerKey] = mapValue(value) - } - } - return next - } - transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) => - tabs.map((tab) => { - terminalTabIds.add(tab.id) - return mapTerminalTab(tab, projection) - }) - ) - // Newer sessions may persist a terminal only in the unified tab model while the legacy - // terminal map is absent. Keep its layout and pane metadata attached to the transferred tab. - for (const [ownerKey, tabs] of Object.entries(source.unifiedTabs ?? {})) { - if (!projection.mapOwnerKey(ownerKey)) { - continue - } - for (const tab of tabs) { - if (tab.contentType === 'terminal') { - terminalTabIds.add(tab.id) - terminalTabIds.add(tab.entityId) - } - } - } - transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) => - files.map((file) => mapOpenFile(file, projection)) - ) - transferred.markdownFrontmatterVisible = mapMarkdownFrontmatterVisible( - source.markdownFrontmatterVisible, - source.openFilesByWorktree, - projection - ) - transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) => - tabs.map((tab) => { - browserWorkspaceIds.add(tab.id) - return mapBrowserWorkspace(tab, projection) - }) - ) - transferred.browserPagesByWorkspace = copyBrowserPages( - source.browserPagesByWorkspace, - browserWorkspaceIds, - projection - ) - for (const field of SESSION_FIELDS_COPIED_BY_OWNER_KEY) { - const record = source[field] as Record | undefined - ;(transferred as Record)[field] = mapOwnerRecord(record, (value) => - structuredClone(value) - ) - } - transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) => - tabs.map((tab) => mapUnifiedTab(tab, projection)) - ) - transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) => - groups.map((group) => mapTabGroup(group, projection)) - ) - transferred.terminalLayoutsByTabId = Object.fromEntries( - [...terminalTabIds].flatMap((tabId) => { - const layout = source.terminalLayoutsByTabId[tabId] - return layout ? [[tabId, structuredClone(layout)] as const] : [] - }) - ) - if (source.localOnlyScrollbackByTabId) { - transferred.localOnlyScrollbackByTabId = Object.fromEntries( - Object.entries(source.localOnlyScrollbackByTabId) - .filter(([tabId]) => terminalTabIds.has(tabId)) - .map(([tabId, buffers]) => [tabId, structuredClone(buffers)]) - ) - } - transferred.terminalPtyIncarnationsByPaneKey = Object.fromEntries( - Object.entries(source.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => - paneBelongsToTabs(paneKey, terminalTabIds) - ) - ) - transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( - Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap( - ([paneKey, tombstone]) => - projection.mapOwnerKey(tombstone.worktreeId) - ? [ - [ - paneKey, - { - ...structuredClone(tombstone), - worktreeId: projection.mapWorktreeId(tombstone.worktreeId) - } - ] as const - ] - : [] - ) - ) - projection.projectSessionFocus?.({ source, transferred, terminalTabIds }) - if (projection.projectSleepingAgentSession) { - const sleepingAgentSessionsByPaneKey = Object.fromEntries( - Object.entries(source.sleepingAgentSessionsByPaneKey ?? {}).flatMap(([paneKey, record]) => { - const projected = projection.projectSleepingAgentSession?.(record) - return projected ? [[paneKey, projected] as const] : [] - }) - ) - if (Object.keys(sleepingAgentSessionsByPaneKey).length > 0) { - transferred.sleepingAgentSessionsByPaneKey = sleepingAgentSessionsByPaneKey - } - } - transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown - ?.filter((worktreeId) => projection.mapOwnerKey(worktreeId) !== null) - .map(projection.mapWorktreeId) - const activeWorktreeId = source.activeWorktreeId - ? projection.mapOwnerKey(source.activeWorktreeId) - : null - if (activeWorktreeId) { - transferred.activeWorktreeId = projection.mapWorktreeId(source.activeWorktreeId!) - } - const activeWorkspaceKey = source.activeWorkspaceKey - ? projection.mapOwnerKey(source.activeWorkspaceKey) - : null - if (activeWorkspaceKey && isWorkspaceKey(activeWorkspaceKey)) { - transferred.activeWorkspaceKey = activeWorkspaceKey - } - return transferred -} - -export function hasTransferredSessionState(session: WorkspaceSessionState): boolean { - return ( - Object.keys(session.tabsByWorktree).length > 0 || - Object.keys(session.openFilesByWorktree ?? {}).length > 0 || - Object.keys(session.markdownFrontmatterVisible ?? {}).length > 0 || - Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || - Object.keys(session.browserPagesByWorkspace ?? {}).length > 0 || - Object.keys(session.unifiedTabs ?? {}).length > 0 || - Object.keys(session.tabGroups ?? {}).length > 0 || - Object.keys(session.terminalLayoutsByTabId ?? {}).length > 0 || - SESSION_FIELDS_COPIED_BY_OWNER_KEY.some( - (field) => Object.keys(session[field] ?? {}).length > 0 - ) || - Object.keys(session.terminalSurfaceTombstonesByPaneKey ?? {}).length > 0 || - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length > 0 || - Boolean(session.activeWorktreeId || session.activeWorkspaceKey) || - (session.activeWorktreeIdsOnShutdown?.length ?? 0) > 0 - ) -} - -function mapTerminalTab(tab: TerminalTab, projection: SessionOwnerProjection): TerminalTab { - const { pendingActivationSpawn: _pendingActivationSpawn, ...persisted } = tab - return { - ...structuredClone(persisted), - worktreeId: projection.mapWorktreeId(tab.worktreeId) - } -} - -function mapOpenFile( - file: PersistedOpenFile, - projection: SessionOwnerProjection -): PersistedOpenFile { - return { - ...structuredClone(file), - worktreeId: projection.mapWorktreeId(file.worktreeId) - } -} - -function mapBrowserWorkspace( - workspace: BrowserWorkspace, - projection: SessionOwnerProjection -): BrowserWorkspace { - return { - ...structuredClone(workspace), - worktreeId: projection.mapWorktreeId(workspace.worktreeId), - ...(workspace.docLocation - ? { - docLocation: remapBrowserPageDocLocation( - workspace.docLocation, - workspace.docLocation.worktreeId, - projection.mapWorktreeId(workspace.docLocation.worktreeId) - ) - } - : {}), - // Why: the browser session profile and partition are source-profile-scoped. - sessionProfileId: null, - sessionPartition: null - } -} - -function mapBrowserPage(page: BrowserPage, projection: SessionOwnerProjection): BrowserPage { - return { - ...structuredClone(page), - worktreeId: projection.mapWorktreeId(page.worktreeId), - ...(page.docLocation - ? { - docLocation: remapBrowserPageDocLocation( - page.docLocation, - page.docLocation.worktreeId, - projection.mapWorktreeId(page.docLocation.worktreeId) - ) - } - : {}) - } -} - -function copyBrowserPages( - pagesByWorkspace: Record | undefined, - workspaceIds: ReadonlySet, - projection: SessionOwnerProjection -): Record { - const next: Record = {} - for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) { - if (workspaceIds.has(workspaceId)) { - next[workspaceId] = pages.map((page) => mapBrowserPage(page, projection)) - } - } - return next -} - -function mapUnifiedTab(tab: Tab, projection: SessionOwnerProjection): Tab { - return { - ...structuredClone(tab), - worktreeId: projection.mapWorktreeId(tab.worktreeId) - } -} - -function mapTabGroup(group: TabGroup, projection: SessionOwnerProjection): TabGroup { - return { ...structuredClone(group), worktreeId: projection.mapWorktreeId(group.worktreeId) } -} - -function paneBelongsToTabs(paneKey: string, tabIds: ReadonlySet): boolean { - const separator = paneKey.lastIndexOf(':') - return separator > 0 && tabIds.has(paneKey.slice(0, separator)) -} diff --git a/src/main/orcad-migration-export-holds.test.ts b/src/main/orcad-migration-export-holds.test.ts deleted file mode 100644 index 53c5b0e1ee2..00000000000 --- a/src/main/orcad-migration-export-holds.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { mkdtempSync, rmSync, statSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getDefaultWorkspaceSession } from '../shared/constants' -import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' -import { writeFileDurableSync } from './durable-file-write' -import { - getTerminalScrollbackSnapshotPath, - readTerminalScrollbackStoredBytesSync, - writeTerminalScrollbackSnapshotSync -} from './terminal-scrollback-snapshots' -import { deleteRemovedTerminalScrollbackSnapshots } from './persistence/loading-store/terminal-session-cleanup' -import { deleteRemovedTerminalScrollbackSnapshotsAsync } from './terminal-scrollback-snapshot-async-migration' -import { extractSessionOwnersForTransfer } from './orca-profiles/profile-session-owner-transfer' -import type { SleepingAgentSessionRecord } from '../shared/agent-session-resume' - -const roots: string[] = [] -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function storage() { - const root = mkdtempSync(join(tmpdir(), 'orcad-export-holds-')) - roots.push(root) - return { root, storage: { snapshotRoot: join(root, 'terminal-scrollback') } } -} - -function sessionWithRef(ref: string | null): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - terminalLayoutsByTabId: ref - ? { - 'tab-1': { - root: null, - activeLeafId: null, - expandedLeafId: null, - scrollbackRefsByLeafId: { 'leaf-1': ref } - } - } - : {} - } -} - -describe('scrollback store reads and retention for migration export', () => { - it('reads stored bytes for a ref and none for an unknown one', () => { - const { storage: store } = storage() - const ref = writeTerminalScrollbackSnapshotSync({ - tabId: 'tab-1', - leafId: 'leaf-1', - buffer: 'saved output', - storage: store - }) - expect(ref).not.toBeNull() - expect(readTerminalScrollbackStoredBytesSync(ref ?? '', store)?.toString('utf8')).toBe( - 'saved output' - ) - expect(readTerminalScrollbackStoredBytesSync(`v1-${'0'.repeat(32)}`, store)).toBeNull() - }) - - it.each(['sync', 'async'] as const)( - 'keeps a %s-removed snapshot a pending export still reads', - async (mode) => { - const { storage: store } = storage() - const ref = - writeTerminalScrollbackSnapshotSync({ - tabId: 'tab-1', - leafId: 'leaf-1', - buffer: 'retained', - storage: store - }) ?? '' - const path = getTerminalScrollbackSnapshotPath(ref, store) ?? '' - const retained = new Set([ref]) - if (mode === 'sync') { - deleteRemovedTerminalScrollbackSnapshots( - sessionWithRef(ref), - sessionWithRef(null), - store, - retained - ) - } else { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - sessionWithRef(ref), - sessionWithRef(null), - store, - retained - ) - } - expect(readFileSync(path, 'utf8')).toBe('retained') - deleteRemovedTerminalScrollbackSnapshots(sessionWithRef(ref), sessionWithRef(null), store) - expect(() => statSync(path)).toThrow() - } - ) -}) - -describe.skipIf(process.platform === 'win32')('durable writes with a creation mode', () => { - it('creates the file with the requested mode', () => { - const { root } = storage() - const finalPath = join(root, 'state.json') - writeFileDurableSync(`${finalPath}.tmp`, finalPath, '{}', 0o600) - expect(statSync(finalPath).mode & 0o777).toBe(0o600) - }) -}) - -describe('session owner projection hooks', () => { - function sleeping(paneKey: string): SleepingAgentSessionRecord { - return { - paneKey, - worktreeId: 'repo-1::/srv/app', - agent: 'claude', - providerSession: { key: 'session_id', id: paneKey }, - prompt: 'resume me', - state: 'done', - capturedAt: 1, - updatedAt: 1 - } - } - - it('lets a transfer keep resumable sleeping agents and project focus scalars', () => { - const source: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - sleepingAgentSessionsByPaneKey: { - 'tab-1:leaf-1': sleeping('tab-1:leaf-1'), - 'tab-2:leaf-1': sleeping('tab-2:leaf-1') - } - } - const projectSessionFocus = vi.fn() - const transferred = extractSessionOwnersForTransfer(source, { - mapOwnerKey: (ownerKey) => ownerKey, - mapWorktreeId: (id) => id, - projectSleepingAgentSession: (record) => (record.paneKey === 'tab-1:leaf-1' ? record : null), - projectSessionFocus - }) - expect(Object.keys(transferred.sleepingAgentSessionsByPaneKey ?? {})).toEqual(['tab-1:leaf-1']) - expect(projectSessionFocus).toHaveBeenCalledWith( - expect.objectContaining({ source, transferred }) - ) - }) - - it('drops sleeping agents when the projection does not opt in', () => { - const transferred = extractSessionOwnersForTransfer( - { - ...getDefaultWorkspaceSession(), - sleepingAgentSessionsByPaneKey: { 'tab-1:leaf-1': sleeping('tab-1:leaf-1') } - }, - { mapOwnerKey: (ownerKey) => ownerKey, mapWorktreeId: (id) => id } - ) - expect(transferred.sleepingAgentSessionsByPaneKey).toBeUndefined() - }) -}) diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 8ce23178f76..4994003eef2 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -131,42 +131,6 @@ afterEach(async () => { }) describe('ElectronServeBrowserProcess start-up', () => { - it('does not launch when startup is already cancelled', async () => { - const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) - started.push(processHandle) - await expect(processHandle.start(AbortSignal.abort())).rejects.toThrow() - expect(spawnProcessMock).not.toHaveBeenCalled() - }) - - it('cancels readiness polling and cleans up the unready sidecar', async () => { - await setControl({ capabilities: [['runtime.v1']] }) - const controller = new AbortController() - const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) - started.push(processHandle) - const starting = processHandle.start(controller.signal) - const outcome = starting.then( - () => ({ rejected: false }), - () => ({ rejected: true }) - ) - try { - await vi.waitFor(async () => expect(await sidecarRequests()).not.toHaveLength(0), { - timeout: 10_000 - }) - } finally { - controller.abort() - await outcome - } - expect(await outcome).toEqual({ rejected: true }) - expect(processHandle.isAvailable()).toBe(false) - const userDataPath = (spawnSpec().args ?? []) - .find((arg) => arg.startsWith('--user-data-dir='))! - .slice('--user-data-dir='.length) - const metadata = JSON.parse(await readFile(join(userDataPath, 'orca-runtime.json'), 'utf8')) - await processHandle.stop() - expect(existsSync(userDataPath)).toBe(false) - expect(() => process.kill(metadata.pid, 0)).toThrow() - }) - it('launches the installed app in headless serve mode without orcad browser env', async () => { for (const key of AGENT_BROWSER_ENVIRONMENT_KEYS) { vi.stubEnv(key, `leaked-${key}`) diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index f03406e1eb0..ae3508289c9 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -97,13 +97,11 @@ export class ElectronServeBrowserProcess { constructor(private readonly executablePath: string) {} - async start(signal?: AbortSignal): Promise { - signal?.throwIfAborted() + async start(): Promise { const temporaryRoot = process.platform === 'win32' ? tmpdir() : '/tmp' const userDataPath = await mkdtemp(join(temporaryRoot, 'orcad-browser-')) this.sidecarDataPath = userDataPath const port = await reserveLoopbackPort() - signal?.throwIfAborted() const child = spawnProcess({ program: this.executablePath, args: [ @@ -124,14 +122,12 @@ export class ElectronServeBrowserProcess { const deadline = Date.now() + START_TIMEOUT_MS let lastError: unknown = null while (Date.now() < deadline) { - signal?.throwIfAborted() const metadata = readRuntimeMetadata(userDataPath) if (metadata) { try { const status = RuntimeStatusResult.parse( await sendOrcadSidecarRequest(metadata, 'status.get', undefined, 5_000) ) - signal?.throwIfAborted() if (status.capabilities?.includes('browser.headless.v1')) { this.metadata = metadata return @@ -144,7 +140,7 @@ export class ElectronServeBrowserProcess { if (child.exitCode !== null || child.signalCode !== null) { break } - await delay(100, undefined, { signal }) + await delay(100) } throw new Error( `Installed Electron browser provider did not become ready: ${ diff --git a/src/main/orcad/external-chromium-browser-process.test.ts b/src/main/orcad/external-chromium-browser-process.test.ts deleted file mode 100644 index 6122a4b2fe4..00000000000 --- a/src/main/orcad/external-chromium-browser-process.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { start, stop, initialize, clear } = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), - initialize: vi.fn(), - clear: vi.fn() -})) -vi.mock('./external-chromium-browser-session', () => ({ - ExternalChromiumBrowserSession: class { - start = start - stop = stop - } -})) -vi.mock('./external-chromium-tab-registry', () => ({ - ExternalChromiumTabRegistry: class { - initialize = initialize - clear = clear - } -})) - -import { ExternalChromiumBrowserProcess } from './external-chromium-browser-process' - -describe('external Chromium startup cancellation', () => { - beforeEach(() => vi.resetAllMocks()) - - it('does not publish a session that resolves after cancellation and permits cleanup', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - return 'tab-live' - }) - const browser = new ExternalChromiumBrowserProcess( - '/opt/orca/agent-browser', - { executablePath: '/opt/orca/chromium', provider: 'chromium' }, - '/state' - ) - await expect(browser.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - expect(start).toHaveBeenCalledWith(controller.signal) - expect(initialize).not.toHaveBeenCalled() - expect(browser.isAvailable()).toBe(false) - await browser.stop() - expect(stop).toHaveBeenCalledWith() - expect(clear).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/orcad/external-chromium-browser-process.ts b/src/main/orcad/external-chromium-browser-process.ts index 112278547cd..912a44775e7 100644 --- a/src/main/orcad/external-chromium-browser-process.ts +++ b/src/main/orcad/external-chromium-browser-process.ts @@ -37,10 +37,8 @@ export class ExternalChromiumBrowserProcess { this.tabs = new ExternalChromiumTabRegistry(this.session) } - async start(signal?: AbortSignal): Promise { - const activeTabId = await this.session.start(signal) - signal?.throwIfAborted() - this.tabs.initialize(activeTabId) + async start(): Promise { + this.tabs.initialize(await this.session.start()) this.available = true } diff --git a/src/main/orcad/external-chromium-browser-session.test.ts b/src/main/orcad/external-chromium-browser-session.test.ts index 989d3f6f217..730a3d36e2d 100644 --- a/src/main/orcad/external-chromium-browser-session.test.ts +++ b/src/main/orcad/external-chromium-browser-session.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdir } from 'node:fs/promises' -const runProcessMock = vi.fn<(spec: Spec) => Promise>() +const runProcessMock = vi.fn() vi.mock('../../shared/child-process/run-process', () => ({ - runProcess: (spec: Spec) => runProcessMock(spec) + runProcess: (spec: unknown) => runProcessMock(spec) })) vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(async () => undefined), @@ -22,7 +21,7 @@ const BASE = { sessionName: 'orca-orcad-0123456789abcdef' } -type Spec = { args?: readonly string[]; env?: NodeJS.ProcessEnv; signal?: AbortSignal } +type Spec = { args?: readonly string[]; env?: NodeJS.ProcessEnv } function commands(): string[][] { return runProcessMock.mock.calls.map((call) => [...((call[0] as Spec).args ?? [])]) @@ -31,60 +30,8 @@ function commands(): string[][] { describe('orcad external-chromium agent-browser environment', () => { beforeEach(() => { runProcessMock.mockReset() - vi.mocked(mkdir).mockClear() }) - it('does no work when startup is already aborted', async () => { - const controller = new AbortController() - controller.abort() - const session = new ExternalChromiumBrowserSession( - '/opt/orca/agent-browser', - { executablePath: BASE.executablePath, provider: 'chromium' }, - '/state' - ) - await expect(session.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - expect(mkdir).not.toHaveBeenCalled() - expect(runProcessMock).not.toHaveBeenCalled() - }) - - it.each(['tab', 'close', 'open'])( - 'does not continue startup after abort during %s', - async (phase) => { - const controller = new AbortController() - runProcessMock.mockImplementation(async (spec: Spec) => { - if (spec.args?.includes(phase)) { - controller.abort() - } - return { - code: 0, - signal: null, - stdout: JSON.stringify({ success: true, data: { tabs: [] } }), - stderr: '', - timedOut: false - } - }) - const session = new ExternalChromiumBrowserSession( - '/opt/orca/agent-browser', - { executablePath: BASE.executablePath, provider: 'chromium' }, - '/state' - ) - await expect(session.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - const specs = runProcessMock.mock.calls.map(([spec]) => spec) - const issued = specs.map((spec) => - spec.args?.find((arg) => ['tab', 'close', 'open'].includes(arg)) - ) - expect(issued).toEqual( - phase === 'tab' ? ['tab'] : phase === 'close' ? ['tab', 'close'] : ['tab', 'close', 'open'] - ) - for (const spec of specs) { - expect(spec.signal).toBe(spec.args?.includes('close') ? undefined : controller.signal) - } - await session.stop() - expect(runProcessMock.mock.lastCall?.[0]).toMatchObject({ signal: undefined }) - expect(commands().at(-1)).toContain('close') - } - ) - // Why: this daemon owns the user's remote Chromium, so an idle bound would close a live browser. it('never bounds the daemon that owns the Chromium tree', () => { const env = externalChromiumAgentBrowserEnvironment({ inheritedEnv: {}, ...BASE }) diff --git a/src/main/orcad/external-chromium-browser-session.ts b/src/main/orcad/external-chromium-browser-session.ts index 56c7bd47e78..de72ae8a061 100644 --- a/src/main/orcad/external-chromium-browser-session.ts +++ b/src/main/orcad/external-chromium-browser-session.ts @@ -87,25 +87,20 @@ export class ExternalChromiumBrowserSession { this.profilePath = join(statePath, `browser-${launch.provider}`) } - async start(signal?: AbortSignal): Promise { - signal?.throwIfAborted() + async start(): Promise { await mkdir(this.profilePath, { recursive: true }) - signal?.throwIfAborted() // Why: the session name is stable across runs, so a daemon an earlier orcad left behind is // still driving the user's Chromium. Unlike the pane bridge this session never passes --cdp, // so nothing binds it to the old process — a surviving one is reusable as-is, and closing it // would take the remote user's browser and every tab with it (#16367). - const reusable = await this.readActiveTabId(signal) - signal?.throwIfAborted() + const reusable = await this.readActiveTabId() if (reusable) { return reusable } // Nothing answered, so anything under this name is wedged or half-dead; reclaim it. await this.stop() - signal?.throwIfAborted() - await this.run(['open', 'about:blank'], COMMAND_TIMEOUT_MS, signal) - const opened = await this.readActiveTabId(signal) - signal?.throwIfAborted() + await this.run(['open', 'about:blank']) + const opened = await this.readActiveTabId() if (!opened) { throw new BrowserError( BROWSER_UNAVAILABLE_ERROR_CODE, @@ -115,12 +110,11 @@ export class ExternalChromiumBrowserSession { return opened } - private async readActiveTabId(signal?: AbortSignal): Promise { + private async readActiveTabId(): Promise { try { - const tabs = await this.readTabs(signal) + const tabs = await this.readTabs() return (tabs.find((tab) => tab.active) ?? tabs[0])?.tabId ?? null } catch { - signal?.throwIfAborted() return null } } @@ -137,10 +131,8 @@ export class ExternalChromiumBrowserSession { await this.run(['tab', agentPageId]) } - async readTabs(signal?: AbortSignal): Promise { - return ( - AgentBrowserTabsResult.parse(await this.run(['tab'], COMMAND_TIMEOUT_MS, signal)).tabs ?? [] - ) + async readTabs(): Promise { + return AgentBrowserTabsResult.parse(await this.run(['tab'])).tabs ?? [] } async screenshot(params: Record, full: boolean): Promise { @@ -160,12 +152,7 @@ export class ExternalChromiumBrowserSession { return { data } } - async run( - command: readonly string[], - timeoutMs = COMMAND_TIMEOUT_MS, - signal?: AbortSignal - ): Promise { - signal?.throwIfAborted() + async run(command: readonly string[], timeoutMs = COMMAND_TIMEOUT_MS): Promise { const args = ['--session', this.sessionName, '--profile', this.profilePath] if (this.launch.browserArgs?.length) { args.push('--args', this.launch.browserArgs.join('\n')) @@ -183,10 +170,8 @@ export class ExternalChromiumBrowserSession { args, env, timeoutMs, - signal, maxOutputBytes: MAX_OUTPUT_BYTES }) - signal?.throwIfAborted() if (result.timedOut) { throw new BrowserError('browser_timeout', 'Browser command timed out.') } diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index 4f1f69763b9..8a7fc60407f 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -3,10 +3,6 @@ import { ORCAD_PROFILE_PREFLIGHT_FLAG, ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG -} from '../../shared/orcad-stop-request' /** * The precondition is only worth anything if it runs first. A loader failure is not @@ -42,12 +38,6 @@ vi.mock('./orcad-native-preflight', () => ({ } })) -vi.mock('./orcad-managed-stop-command', () => ({ - runOrcadManagedStopCommandAndExit: async (argv: string[]) => { - order.push(`managed-stop:${argv.join(' ')}`) - } -})) - vi.mock('./orcad-entry', () => ({ main: async () => { order.push('main') @@ -77,15 +67,4 @@ describe('orcad entry', () => { expect(order).toEqual(['profile-admission', 'preflight', 'main']) }) - - it.each([ORCAD_COMPLETE_MANAGED_STOP_FLAG, ORCAD_CANCEL_MANAGED_STOP_FLAG])( - 'runs %s without preflights, the bundled handoff, or a runtime', - async (flag) => { - vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', flag, '{}']) - await import('./main') - await vi.waitFor(() => expect(order).toHaveLength(1)) - - expect(order).toEqual([`managed-stop:${flag} {}`]) - } - ) }) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 211fc94540d..46f98853c02 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -8,10 +8,6 @@ import { } from '../../shared/orcad-profile-preflight' import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' import { handoffToBundledOrcad } from './orcad-bundled-runtime' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG -} from '../../shared/orcad-stop-request' // Why exit before the preflight: reaching this line means the whole module graph resolved // under plain Node, which is all the build guard needs to prove. Probing natives or @@ -34,42 +30,28 @@ function failStartup(error: unknown): void { process.exit(resolveOrcadExitCode(error)) } -// Why before the bundled handoff and preflights: completing a stop must not start a runtime. -if ( - process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG || - process.argv[2] === ORCAD_CANCEL_MANAGED_STOP_FLAG -) { - void import('./orcad-managed-stop-command').then(({ runOrcadManagedStopCommandAndExit }) => - runOrcadManagedStopCommandAndExit(process.argv.slice(2)) - ) -} else { - startOrcadProcess() -} - -function startOrcadProcess(): void { - try { - if (!handoffToBundledOrcad()) { - const flag = process.argv[2] - if ( - (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && - process.argv.length === 4 - ) { - void runOrcadProfilePreflight(process.argv[3], { - nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG +try { + if (!handoffToBundledOrcad()) { + const flag = process.argv[2] + if ( + (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && + process.argv.length === 4 + ) { + void runOrcadProfilePreflight(process.argv[3], { + nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + }) + // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. + .then(() => process.stdout.write('', () => process.exit(0))) + .catch(failStartup) + } else { + void preflightBundledOrcadStartup() + .then(() => { + runOrcadNativePreflight() + return main() }) - // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. - .then(() => process.stdout.write('', () => process.exit(0))) - .catch(failStartup) - } else { - void preflightBundledOrcadStartup() - .then(() => { - runOrcadNativePreflight() - return main() - }) - .catch(failStartup) - } + .catch(failStartup) } - } catch (error) { - failStartup(error) } +} catch (error) { + failStartup(error) } diff --git a/src/main/orcad/orcad-browser-provider.ts b/src/main/orcad/orcad-browser-provider.ts index a350ef8f9f9..fdb8bfad34b 100644 --- a/src/main/orcad/orcad-browser-provider.ts +++ b/src/main/orcad/orcad-browser-provider.ts @@ -23,7 +23,6 @@ export type OrcadBrowserProvider = { export type OrcadBrowserProviderOptions = { userDataPath: string - signal?: AbortSignal environment?: NodeJS.ProcessEnv resolveInstalledElectronExecutable?: () => Promise resolveAgentBrowserBinary?: () => string | null @@ -31,20 +30,6 @@ export type OrcadBrowserProviderOptions = { type ExecutableProbe = 'ok' | 'missing' | 'not_executable' -class OrcadBrowserCleanupError extends AggregateError {} - -async function cleanupFailedProvider( - processHandle: { stop(): Promise }, - startupError: unknown -): Promise { - try { - await processHandle.stop() - } catch (cleanupError) { - throw new OrcadBrowserCleanupError([startupError, cleanupError], 'orcad_browser_cleanup_failed') - } - throw startupError -} - /** Splits the two failures apart: a wrong path and a forgotten chmod +x need different fixes. */ async function probeExecutable(path: string): Promise { const mode = process.platform === 'win32' ? constants.F_OK : constants.X_OK @@ -114,14 +99,14 @@ export async function resolveInstalledElectronExecutable(): Promise { const processHandle = new ExternalChromiumBrowserProcess(agentBrowserPath, launch, userDataPath) try { - await processHandle.start(signal) + await processHandle.start() } catch (error) { - return cleanupFailedProvider(processHandle, error) + await processHandle.stop() + throw error } return { kind: launch.provider, @@ -131,15 +116,13 @@ async function startProvider( } } -async function startElectronServeProvider( - executablePath: string, - signal?: AbortSignal -): Promise { +async function startElectronServeProvider(executablePath: string): Promise { const processHandle = new ElectronServeBrowserProcess(executablePath) try { - await processHandle.start(signal) + await processHandle.start() } catch (error) { - return cleanupFailedProvider(processHandle, error) + await processHandle.stop() + throw error } return { kind: 'electron', @@ -154,9 +137,6 @@ export async function resolveOrcadBrowserProvider( options: OrcadBrowserProviderOptions ): Promise { const environment = options.environment ?? process.env - if (options.signal?.aborted) { - return null - } await mkdir(options.userDataPath, { recursive: true, mode: 0o700 }) const declined = (cause: RuntimeBrowserUnavailableCause): null => { @@ -167,23 +147,14 @@ export async function resolveOrcadBrowserProvider( const installedElectronExecutable = await ( options.resolveInstalledElectronExecutable ?? resolveInstalledElectronExecutable )() - if (options.signal?.aborted) { - return null - } // Why held rather than reported now: Chromium may still resolve, and if it does not, its // own concrete fault is the more actionable one for an operator who set the env var. let electronFailure: RuntimeBrowserUnavailableCause | null = null if (installedElectronExecutable) { try { setRuntimeBrowserUnavailableCause(null) - return await startElectronServeProvider(installedElectronExecutable, options.signal) + return await startElectronServeProvider(installedElectronExecutable) } catch (error) { - if (error instanceof OrcadBrowserCleanupError) { - throw error - } - if (options.signal?.aborted) { - return null - } console.warn('[orcad] Installed Electron browser provider unavailable:', error) electronFailure = { reason: 'electron_start_failed', detail: errorDetail(error) } } @@ -203,9 +174,6 @@ export async function resolveOrcadBrowserProvider( } const probe = await probeExecutable(chromiumExecutable) - if (options.signal?.aborted) { - return null - } if (probe !== 'ok') { return declined({ reason: probe === 'missing' ? 'executable_not_found' : 'executable_not_executable', @@ -218,16 +186,9 @@ export async function resolveOrcadBrowserProvider( return await startProvider( agentBrowserPath, { executablePath: chromiumExecutable, provider: 'chromium' }, - options.userDataPath, - options.signal + options.userDataPath ) } catch (error) { - if (error instanceof OrcadBrowserCleanupError) { - throw error - } - if (options.signal?.aborted) { - return null - } console.warn('[orcad] External Chromium browser provider unavailable:', error) return declined({ reason: 'chromium_start_failed', detail: errorDetail(error) }) } diff --git a/src/main/orcad/orcad-browser-startup-cancellation.test.ts b/src/main/orcad/orcad-browser-startup-cancellation.test.ts deleted file mode 100644 index 9eca8d4e1da..00000000000 --- a/src/main/orcad/orcad-browser-startup-cancellation.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { resolveOrcadBrowserProvider } from './orcad-browser-provider' - -const { start, stop, chromiumStart } = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), - chromiumStart: vi.fn() -})) -vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(), access: vi.fn() })) -vi.mock('./electron-serve-browser-process', () => ({ - ElectronServeBrowserProcess: class { - start = start - stop = stop - } -})) -vi.mock('./external-chromium-browser-process', () => ({ - ExternalChromiumBrowserProcess: class { - start = chromiumStart - } -})) -beforeEach(() => vi.resetAllMocks()) - -const options = { - userDataPath: '/fixture', - resolveInstalledElectronExecutable: async () => '/fixture/electron', - resolveAgentBrowserBinary: () => '/fixture/driver', - environment: { ORCA_BROWSER_EXECUTABLE: '/fixture/chromium' } -} - -it('does not launch a provider after pre-cancellation', async () => { - await expect( - resolveOrcadBrowserProvider({ ...options, signal: AbortSignal.abort() }) - ).resolves.toBeNull() - expect(start).not.toHaveBeenCalled() - expect(chromiumStart).not.toHaveBeenCalled() -}) - -it('cleans cancelled Electron startup without launching a fallback', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - controller.signal.throwIfAborted() - }) - await expect( - resolveOrcadBrowserProvider({ ...options, signal: controller.signal }) - ).resolves.toBeNull() - expect(stop).toHaveBeenCalledOnce() - expect(chromiumStart).not.toHaveBeenCalled() -}) - -it('propagates failed cleanup even when startup was cancelled', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - controller.signal.throwIfAborted() - }) - stop.mockRejectedValue(new Error('sidecar still owned')) - await expect( - resolveOrcadBrowserProvider({ ...options, signal: controller.signal }) - ).rejects.toThrow('orcad_browser_cleanup_failed') - expect(chromiumStart).not.toHaveBeenCalled() -}) diff --git a/src/main/orcad/orcad-browser-startup.test.ts b/src/main/orcad/orcad-browser-startup.test.ts deleted file mode 100644 index 9f2f5812ac7..00000000000 --- a/src/main/orcad/orcad-browser-startup.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { startOrcadBrowserProvider } from './orcad-browser-startup' -import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' -import { - createRuntimeBrowserCommands, - runtimeBrowserCommandsFactoryIsHeadless, - runtimeBrowserUnavailableCause, - setRuntimeBrowserCommandsFactory, - setRuntimeBrowserUnavailableCause -} from '../runtime/runtime-browser-commands-factory' -import type { - RuntimeBrowserCommandHost, - RuntimeBrowserCommands -} from '../runtime/orca-runtime-browser' - -vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() })) -afterEach(() => { - vi.restoreAllMocks() - setRuntimeBrowserCommandsFactory(null) - setRuntimeBrowserUnavailableCause(null) -}) - -const host: RuntimeBrowserCommandHost = Object.create(null) -function delayedProvider() { - const pending = Promise.withResolvers() - vi.mocked(resolveOrcadBrowserProvider).mockReturnValueOnce(pending.promise) - const command = vi.fn(() => ({ tabs: [] })) - const provider: OrcadBrowserProvider = { - kind: 'electron', - factory: vi.fn((): RuntimeBrowserCommands => - Object.assign(Object.create(null), { browserTabList: command }) - ), - isAvailable: vi.fn(() => true), - stop: vi.fn(async () => {}) - } - const startup = startOrcadBrowserProvider({ userDataPath: '/private-fixture' }) - return { pending, provider, command, startup } -} - -it('returns before discovery and keeps already-created commands usable after readiness', async () => { - const { pending, provider, command, startup } = delayedProvider() - const commands = createRuntimeBrowserCommands(host) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) - expect(() => commands.browserTabList({})).toThrow(/unavailable/) - pending.resolve(provider) - await startup.ready - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(true) - commands.browserTabList({}) - commands.browserTabList({}) - expect(provider.factory).toHaveBeenCalledTimes(1) - expect(command).toHaveBeenCalledTimes(2) - await startup.stop() - expect(() => commands.browserTabList({})).toThrow(/unavailable/) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) -}) - -it('aborts pending discovery and awaits a late provider cleanup exactly once', async () => { - const { pending, provider, startup } = delayedProvider() - await Promise.resolve() - const signal = vi.mocked(resolveOrcadBrowserProvider).mock.calls.at(-1)![0].signal! - const stopped = startup.stop() - expect(startup.stop()).toBe(stopped) - expect(signal.aborted).toBe(true) - pending.resolve(provider) - await stopped - expect(provider.stop).toHaveBeenCalledTimes(1) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) -}) - -it('retains specific provider-unavailable diagnostics after discovery declines', async () => { - const { pending, startup } = delayedProvider() - setRuntimeBrowserUnavailableCause({ reason: 'driver_missing' }) - pending.resolve(null) - await startup.ready - expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'driver_missing' }) - await startup.stop() -}) - -it('does not hide resolver cleanup failures from the runtime lifetime', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { pending, startup } = delayedProvider() - pending.reject(new Error('cleanup failed')) - await startup.ready - await expect(startup.stop()).rejects.toThrow('cleanup failed') -}) - -it('propagates ready-provider cleanup failures', async () => { - const { pending, provider, startup } = delayedProvider() - vi.mocked(provider.stop).mockRejectedValueOnce(new Error('stop failed')) - pending.resolve(provider) - await startup.ready - await expect(startup.stop()).rejects.toThrow('stop failed') -}) - -it('refuses a member the provider does not implement instead of invoking it', async () => { - const { pending, provider, startup } = delayedProvider() - const commands = createRuntimeBrowserCommands(host) - pending.resolve(provider) - await startup.ready - // The fixture provider implements only browserTabList. - expect(() => commands.browserTabCreate({ worktree: 'wt-a' })).toThrow('Unknown browser command') - await startup.stop() -}) diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts deleted file mode 100644 index fb7dc03a0ab..00000000000 --- a/src/main/orcad/orcad-browser-startup.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { BrowserError } from '../browser/browser-error' -import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' -import type { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' -import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { - resolveOrcadBrowserProvider, - type OrcadBrowserProvider, - type OrcadBrowserProviderOptions -} from './orcad-browser-provider' - -/** Browser discovery must not hold core RPC readiness hostage to a desktop authorization UI. */ -export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): { - ready: Promise - stop(): Promise -} { - const controller = new AbortController() - let provider: OrcadBrowserProvider | null = null - let startupError: unknown - let stopping: Promise | undefined - setRuntimeBrowserCommandsFactory( - (host) => { - let commands: RuntimeBrowserCommands | undefined - // Every member resolves through the getter, so the target needs no members of its own. - const target: RuntimeBrowserCommands = Object.create(null) - return new Proxy(target, { - get: (_target, property) => { - if (property === 'then' || typeof property !== 'string') { - return undefined - } - return (...args: unknown[]) => { - if (controller.signal.aborted || !provider?.isAvailable()) { - throw new BrowserError( - BROWSER_UNAVAILABLE_ERROR_CODE, - 'Browser automation is unavailable on this host.' - ) - } - commands ??= provider.factory(host) - return callBrowserCommand(commands, property, args) - } - } - }) - }, - { headless: true, isAvailable: () => !controller.signal.aborted && !!provider?.isAvailable() } - ) - const ready = Promise.resolve() - .then(() => resolveOrcadBrowserProvider({ ...options, signal: controller.signal })) - .then( - (resolved) => { - provider = resolved - if (!resolved && !controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) - } - }, - (error: unknown) => { - startupError = error - if (!controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) - console.warn('[orcad] Browser startup failed:', error) - } - } - ) - return { - ready, - stop: () => { - controller.abort() - stopping ??= ready.then(async () => { - await provider?.stop() - // Unexpected resolver errors may include failed cleanup of a partially started provider. - if (startupError) { - throw startupError - } - }) - return stopping - } - } -} - -type BrowserCommandMember = (this: RuntimeBrowserCommands, ...args: unknown[]) => unknown - -function isBrowserCommandMember(value: unknown): value is BrowserCommandMember { - return typeof value === 'function' -} - -/** The proxy forwards by name; anything that is not a command method is refused, not invoked. */ -function callBrowserCommand( - commands: RuntimeBrowserCommands, - name: string, - args: unknown[] -): unknown { - const member: unknown = name in commands ? commands[name] : undefined - if (!isBrowserCommandMember(member)) { - throw new BrowserError(BROWSER_UNAVAILABLE_ERROR_CODE, `Unknown browser command: ${name}`) - } - return member.call(commands, ...args) -} diff --git a/src/main/orcad/orcad-completed-stop-receipt.ts b/src/main/orcad/orcad-completed-stop-receipt.ts deleted file mode 100644 index aa89cce87d5..00000000000 --- a/src/main/orcad/orcad-completed-stop-receipt.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** Durable outcomes of a managed stop, in the data root beside the instance lock. */ -import { lstatSync } from 'node:fs' -import { dirname, join } from 'node:path' -import type { z } from 'zod' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - ORCAD_STOP_RECEIPTS_DIRNAME, - OrcadCompletedStopReceiptSchema, - OrcadManagedStopRequestSchema, - OrcadDaemonRetirementRecordSchema, - type OrcadCompletedStopReceipt, - type OrcadDaemonRetirementRecord, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -const RECEIPT_MAX_BYTES = 64 * 1024 - -export type ReceiptKind = 'completed' | 'retirement' | 'decision' - -export function orcadStopReceiptPath(request: OrcadManagedStopRequest, kind: ReceiptKind): string { - const { transactionId, instance } = OrcadManagedStopRequestSchema.parse(request) - const suffix = kind === 'completed' ? '' : `.${kind}` - return join( - dirname(instance.lockPath), - ORCAD_STOP_RECEIPTS_DIRNAME, - `${transactionId}${suffix}.json` - ) -} - -/** `null` when absent; a receipt for a different request throws rather than reading as absent. */ -export function readOrcadStopReceipt( - request: OrcadManagedStopRequest, - kind: ReceiptKind, - schema: z.ZodType -): T | null { - const path = orcadStopReceiptPath(request, kind) - try { - if (!lstatSync(path).isFile()) { - throw new Error('orcad_stop_receipt_unverifiable') - } - } catch (error) { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { - return null - } - throw error - } - const receipt = schema.parse( - JSON.parse(readNodeFileSyncWithinLimit(path, RECEIPT_MAX_BYTES).buffer.toString('utf8')) - ) - const expected = OrcadManagedStopRequestSchema.parse(request) - if (JSON.stringify(receipt.request) !== JSON.stringify(expected)) { - throw new Error('orcad_stop_receipt_mismatch') - } - return receipt -} - -function writeReceipt(request: OrcadManagedStopRequest, kind: ReceiptKind, receipt: unknown): void { - if (!writeDurableSecureJsonFile(orcadStopReceiptPath(request, kind), receipt)) { - throw new Error('orcad_stop_receipt_permissions_unconfirmed') - } -} - -export function readOrcadCompletedStopReceipt( - request: OrcadManagedStopRequest -): OrcadCompletedStopReceipt | null { - return readOrcadStopReceipt(request, 'completed', OrcadCompletedStopReceiptSchema) -} - -/** - * Called only after exit is proven; rewriting an existing receipt re-runs its fsync. A retiring - * request whose orcad left no retirement record reports `unverifiable`, never `retired`. - */ -export function persistOrcadCompletedStopReceipt( - request: OrcadManagedStopRequest, - exitedAt: Date -): OrcadCompletedStopReceipt { - const parsed = OrcadManagedStopRequestSchema.parse(request) - const retirement = parsed.retireIdleDaemon - ? (readOrcadDaemonRetirementRecord(parsed)?.retirement ?? 'unverifiable') - : undefined - const receipt = readOrcadCompletedStopReceipt(parsed) ?? { - schemaVersion: 1 as const, - kind: 'orcad_managed_stop_completed' as const, - request: parsed, - exitedAt: exitedAt.toISOString(), - ...(retirement ? { retirement } : {}) - } - writeReceipt(parsed, 'completed', receipt) - return receipt -} - -export function readOrcadDaemonRetirementRecord( - request: OrcadManagedStopRequest -): OrcadDaemonRetirementRecord | null { - return readOrcadStopReceipt(request, 'retirement', OrcadDaemonRetirementRecordSchema) -} - -export function persistOrcadDaemonRetirementRecord( - request: OrcadManagedStopRequest, - outcome: Pick -): void { - writeReceipt(request, 'retirement', { - schemaVersion: 1, - kind: 'orcad_managed_stop_retirement', - request: OrcadManagedStopRequestSchema.parse(request), - ...outcome - }) -} diff --git a/src/main/orcad/orcad-daemon-retirement.test.ts b/src/main/orcad/orcad-daemon-retirement.test.ts deleted file mode 100644 index 01c0d780da5..00000000000 --- a/src/main/orcad/orcad-daemon-retirement.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../daemon/daemon-init', () => ({ - requestIdleDaemonRetirement: vi.fn(), - listLiveDaemonSessions: vi.fn(), - releaseDaemonRetirementFence: vi.fn() -})) - -import type { DaemonIdleRetirementResult } from '../daemon/daemon-pty-runtime-state' -import { retireOrcadDaemonIfIdle } from './orcad-daemon-retirement' - -function ports( - result: () => Promise, - liveSessions: number | null = null -) { - return { - request: vi.fn(result), - releaseFence: vi.fn(), - countLiveSessions: vi.fn(async () => liveSessions), - timeoutMs: 20 - } -} - -describe('best-effort daemon retirement', () => { - it('reports retired only when the daemon accepted, and keeps its fence', async () => { - const retiring = ports(async () => ({ state: 'retiring' })) - expect(await retireOrcadDaemonIfIdle(retiring)).toMatchObject({ retirement: 'retired' }) - expect(retiring.releaseFence).not.toHaveBeenCalled() - }) - - it('leaves a busy daemon running, reopens admission, and reports live', async () => { - const busy = ports(async () => ({ state: 'busy', liveSessions: 3 })) - expect(await retireOrcadDaemonIfIdle(busy)).toMatchObject({ - retirement: 'live', - liveSessions: 3 - }) - expect(busy.releaseFence).toHaveBeenCalledOnce() - }) - - it('counts sessions itself when the daemon did not say how many', async () => { - const busy = ports(async () => ({ state: 'busy', liveSessions: null }), 1) - expect(await retireOrcadDaemonIfIdle(busy)).toMatchObject({ - retirement: 'live', - liveSessions: 1 - }) - }) - - it.each([ - [ - 'an unsupported daemon', - async (): Promise => ({ state: 'unsupported' }) - ], - [ - 'an unverifiable census', - async (): Promise => ({ state: 'unverifiable' }) - ], - [ - 'lost contact', - async (): Promise => { - throw new Error('socket closed') - } - ], - ['no answer', () => new Promise(() => {})] - ])('never reads %s as idle, and reopens admission', async (_name, result) => { - const unanswered = ports(result) - expect(await retireOrcadDaemonIfIdle(unanswered)).toMatchObject({ - retirement: 'unverifiable', - liveSessions: null - }) - expect(unanswered.releaseFence).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/orcad/orcad-daemon-retirement.ts b/src/main/orcad/orcad-daemon-retirement.ts deleted file mode 100644 index c5f0bb3936f..00000000000 --- a/src/main/orcad/orcad-daemon-retirement.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Best-effort retirement of the terminal daemon when a managed stop asks for it. - * - * The daemon normally outlives orcad so terminals survive a restart (D7). Retirement happens - * only when the daemon itself proves it owns no live session across every generation; a busy - * or unanswering daemon stays up, and orcad's own stop never waits on that outcome. - */ -import { - listLiveDaemonSessions, - releaseDaemonRetirementFence, - requestIdleDaemonRetirement -} from '../daemon/daemon-init' -import type { OrcadDaemonRetirementVerdict } from '../../shared/orcad-stop-request' - -export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000 - -export type OrcadDaemonRetirement = { - retirement: OrcadDaemonRetirementVerdict - liveSessions: number | null - reason: string | null -} - -/** Live sessions across every daemon generation, or `null` when any could not answer. */ -export async function countLiveOrcadDaemonSessions(): Promise { - try { - const sessions = await listLiveDaemonSessions() - return sessions ? sessions.filter((session) => session.isAlive).length : null - } catch { - return null - } -} - -type RetirementPorts = { - request: typeof requestIdleDaemonRetirement - releaseFence: typeof releaseDaemonRetirementFence - countLiveSessions: typeof countLiveOrcadDaemonSessions - timeoutMs: number -} - -const DEFAULT_PORTS: RetirementPorts = { - request: requestIdleDaemonRetirement, - releaseFence: releaseDaemonRetirementFence, - countLiveSessions: countLiveOrcadDaemonSessions, - timeoutMs: ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS -} - -export async function retireOrcadDaemonIfIdle( - ports: Partial = {} -): Promise { - const { request, releaseFence, countLiveSessions, timeoutMs } = { ...DEFAULT_PORTS, ...ports } - const result = await withTimeout( - request().catch(() => ({ state: 'unverifiable' as const })), - timeoutMs, - { state: 'timed-out' as const } - ) - if (result.state === 'retiring') { - return { retirement: 'retired', liveSessions: 0, reason: null } - } - // A daemon that stays must not be left refusing new terminals. - releaseFence() - const liveSessions = - result.state === 'busy' && result.liveSessions !== null - ? result.liveSessions - : await withTimeout(countLiveSessions(), timeoutMs, null) - if (liveSessions !== null && liveSessions > 0) { - return { - retirement: 'live', - liveSessions, - reason: - `${liveSessions} terminal ${liveSessions === 1 ? 'session is' : 'sessions are'} still ` + - 'live, so the daemon stays up and keeps them.' - } - } - return { - retirement: 'unverifiable', - liveSessions, - reason: - result.state === 'unsupported' - ? 'The terminal daemon predates idle retirement, so it was left running.' - : 'The host could not prove the daemon idle, so it was left running.' - } -} - -/** No answer within the bound is `fallback`; the caller treats it as unverifiable. */ -async function withTimeout(work: Promise, timeoutMs: number, fallback: F): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - work, - new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), timeoutMs) - }) - ]) - } finally { - clearTimeout(timer) - } -} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 2aa611658b7..854b4dfb723 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -22,10 +22,6 @@ import { startOrcadWithHost } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' -import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' -import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' -import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' -import type { OrcadManagedStopContext } from '../../shared/orcad-stop-request' import { changedAiVaultSearchSettings, type AiVaultSearchSettings @@ -41,18 +37,13 @@ function createNodeAppEnvironment(): AppEnvironment { // The main signal handler awaits runtime and browser teardown before process.exit. // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. runOrcadQuitHandlers = (): void => { - const errors: unknown[] = [] for (const handler of quitHandlers.splice(0)) { try { handler() } catch (error) { - errors.push(error) + console.error('[orcad] shutdown handler failed:', error) } } - // Why throw: a quit handler that failed may leave a writer running, which keeps the lock. - if (errors.length > 0) { - throw new AggregateError(errors, 'orcad_quit_handlers_failed') - } } return { getPath: resolveOrcadPath, @@ -106,8 +97,6 @@ export type OrcadOptions = { export type OrcadHandle = { readiness: ServeReadiness - /** What an instance-bound stop request must name to stop this process. */ - managedStop: OrcadManagedStopContext stop(): Promise } @@ -118,7 +107,7 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() - const { readiness, instance, stop } = await startOrcadWithHost( + return startOrcadWithHost( resolveUserDataPath(), (registerCleanup) => startOrcadRuntime(options, registerCleanup), () => { @@ -128,13 +117,11 @@ export async function startOrcad(options: OrcadOptions = {}): Promise {} } ) - const version = process.env.ORCA_VERSION ?? '0.0.0-orcad' - return { readiness, managedStop: { version, runtimeId: readiness.runtimeId, instance }, stop } } async function startOrcadRuntime( options: OrcadOptions, - registerCleanup: (cleanup: OrcadRuntimeCleanup) => void + registerCleanup: (cleanup: () => Promise) => void ): Promise> { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') @@ -158,28 +145,35 @@ async function startOrcadRuntime( const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = await import('../runtime/agent-status-observed-pane-identity') - const { disposeWatcherProcessAndWait } = await import('../ipc/parcel-watcher-process') - + let rpc: InstanceType | null = null let profileStoreForShutdown: | { flushFinalOrThrowAsync(): Promise; freezeWritesAsync(): Promise } | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} - // Cleanups run in reverse: RPC, then recovery and watchers, then the final flush, then daemon. - registerCleanup(() => agentHookServer.stop()) - registerCleanup(() => uninstallHookStatusRepublish()) - registerCleanup(() => uninstallObservedStatusIdentity()) - // Why disconnect and not shut down: the daemon must outlive this process, or an orcad - // restart goes back to killing every running terminal. - registerCleanup(() => stopOrcadDaemon()) registerCleanup(async () => { - // A SQLite-backed orcad has no JSON mirror to absorb a debounced write after SIGTERM. - if (profileStoreForShutdown) { - await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + try { + await rpc?.stop() + } finally { + try { + // Stop accepting RPC writes before the final persistence barrier. A SQLite-backed + // orcad has no JSON mirror to absorb a debounced write after SIGTERM. + if (profileStoreForShutdown) { + await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + } + } finally { + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } + } } }) - // Watcher children outlive a disposal that does not wait for them. - registerCleanup(() => disposeWatcherProcessAndWait()) const { DesktopPushService } = await import('../runtime/push/desktop-push-service') const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') @@ -302,14 +296,11 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() - // A retry armed during recovery would otherwise write after the final profile flush. - registerCleanup(() => runtime.stopLegacyWorkerTerminalRecovery()) - // Recovery binds terminal and dispatch identities; only now can startup observations be fenced. observedStatusCapture.attach(runtime) const bindHost = resolveOrcadBindHost(options.bind) - const rpc = new OrcaRuntimeRpcServer({ + rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, @@ -320,8 +311,6 @@ async function startOrcadRuntime( pinnedBindHost: bindHost, ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) }) - // Stops first: no RPC may write while the rest of the runtime is torn down. - registerCleanup(() => rpc.stop()) await rpc.start() const pushService = DesktopPushService.create({ runtime, @@ -399,12 +388,6 @@ export { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-lifecycle' export async function main(argv: string[] = process.argv.slice(2)): Promise { const startup = startOrcad(parseArgs(argv)) - const requestShutdown = installOrcadShutdownSignals(async () => (await startup).stop()) - const handle = await startup - // Why after startup: a managed request must name the runtime and instance this run became. - installOrcadStopRequestListeners(() => requestShutdown('stop request'), { - installRoot: resolveOrcadInstallRoot(), - managedStop: handle.managedStop, - beforeManagedStop: prepareOrcadManagedStop - }) + installOrcadShutdownSignals(async () => (await startup).stop()) + await startup } diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index 4f75c7c9025..70e02b37e87 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -11,21 +11,13 @@ const { readDaemonPidRecordMock, daemonOwnsFreshPersistentPtysMock } = vi.hoisted(() => ({ - checkDaemonHealthMock: vi.fn<(socketPath: string, tokenPath: string) => Promise>(), + checkDaemonHealthMock: vi.fn<() => Promise>(), getDaemonEndpointFactsMock: vi.fn<() => unknown>(), readDaemonPidRecordMock: vi.fn<() => ParsedDaemonPid | null>(), daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>() })) -// The real coverage fallback is per platform; a daemon may also report its own coverage. -const reportedCoverage = vi.hoisted(() => ({ value: new Array<'pty-spawn' | 'handshake'>() })) -vi.mock('../daemon/daemon-health', () => ({ - checkDaemonHealthWithCoverage: async (socketPath: string, tokenPath: string) => ({ - verdict: await checkDaemonHealthMock(socketPath, tokenPath), - coverage: - reportedCoverage.value.shift() ?? (process.platform === 'win32' ? 'handshake' : 'pty-spawn') - }) -})) +vi.mock('../daemon/daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock })) vi.mock('../daemon/daemon-init', () => ({ getDaemonEndpointFacts: getDaemonEndpointFactsMock, readDaemonPidRecord: readDaemonPidRecordMock, @@ -131,12 +123,6 @@ describe('collectTerminalDaemonHealth', () => { // green verdict there must not be reported as a PTY round trip. expect(health.selfTest.coverage).toBe('handshake') }) - - it('reports the coverage the daemon says its probe achieved', async () => { - reportedCoverage.value.push('handshake') - const health = await collectTerminalDaemonHealth() - expect(health.selfTest).toMatchObject({ ok: true, coverage: 'handshake' }) - }) }) describe('collectOrcadHealth', () => { diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index 6507763f3cf..0c3a3525171 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -11,14 +11,13 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import process from 'node:process' -import { checkDaemonHealthWithCoverage, type DaemonHealth } from '../daemon/daemon-health' +import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health' import { daemonOwnsFreshPersistentPtys, getDaemonEndpointFacts, readDaemonPidRecord } from '../daemon/daemon-init' import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' -import { ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' /** * How much a green self-test actually proves. @@ -68,11 +67,6 @@ export type OrcadHealth = { terminalDaemon: TerminalDaemonHealth /** The low-cardinality profile-state authority selected during startup, when available. */ profileStateAuthority?: OrcadProfileStateAuthoritySelection - /** - * Present when this build consumes stop-request files and answers the managed-stop commands. - * Absent on older builds, which a client must keep stopping with SIGTERM. - */ - stopRequests?: typeof ORCAD_STOP_REQUESTS_CAPABILITY } /** @@ -106,18 +100,14 @@ export async function runTerminalDaemonSelfTest( now: () => number = () => Date.now() ): Promise { const startedAt = now() + // Why: `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a + // green verdict there covers the handshake only. Say so instead of overclaiming. + const coverage: PtySelfTestCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' const facts = getDaemonEndpointFacts() if (!facts) { - // Why: `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a - // green verdict there covers the handshake only. Say so instead of overclaiming. - const coverage: PtySelfTestCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' return { ok: false, coverage, verdict: 'no-daemon', durationMs: now() - startedAt } } - // The daemon reports what its probe actually did; an older daemon falls back by platform. - const { verdict, coverage } = await checkDaemonHealthWithCoverage( - facts.socketPath, - facts.tokenPath - ) + const verdict = await checkDaemonHealth(facts.socketPath, facts.tokenPath) return { ok: verdict === 'healthy', coverage, verdict, durationMs: now() - startedAt } } @@ -172,7 +162,6 @@ export async function collectOrcadHealth( arch: process.arch, pid: process.pid, terminalDaemon: await collectTerminalDaemonHealth(), - ...(profileStateAuthority ? { profileStateAuthority } : {}), - stopRequests: ORCAD_STOP_REQUESTS_CAPABILITY + ...(profileStateAuthority ? { profileStateAuthority } : {}) } } diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts index 354976a72ba..45f7543c515 100644 --- a/src/main/orcad/orcad-instance-lock.test.ts +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, statSync, writeFileSync @@ -15,8 +14,7 @@ import { acquireOrcadInstanceLock, ORCAD_LOCK_FILE_NAME, OrcadInstanceLockError, - type OrcadInstanceLockHooks, - type OrcadLockRecord + type OrcadInstanceLockHooks } from './orcad-instance-lock' const roots: string[] = [] @@ -39,18 +37,6 @@ function hooks(overrides: OrcadInstanceLockHooks = {}): OrcadInstanceLockHooks { } } -function persistedRecord(overrides: Partial = {}): OrcadLockRecord { - return { - pid: 424242, - startedAtMs: 1, - identity: 'uid-1000', - version: '1.0.0-test', - acquiredAt: '2026-01-01T00:00:00.000Z', - nonce: 'stale', - ...overrides - } -} - afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) @@ -82,14 +68,20 @@ describe('acquireOrcadInstanceLock', () => { it('reclaims the record of a holder that is gone', () => { const root = makeRoot() - writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), JSON.stringify(persistedRecord())) + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) const lock = acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false })) expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) }) it('treats a live pid whose start time does not match as a recycled pid, not a holder', () => { const root = makeRoot() - writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), JSON.stringify(persistedRecord())) + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) const lock = acquireOrcadInstanceLock( root, hooks({ processIsAlive: () => true, startTimeMatches: () => false }) @@ -97,37 +89,11 @@ describe('acquireOrcadInstanceLock', () => { expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) }) - it('does not displace a successor published between stale inspection and reclaim', () => { - const root = makeRoot() - const lockPath = join(root, ORCAD_LOCK_FILE_NAME) - const successor = persistedRecord({ pid: 777, startedAtMs: 2, nonce: 'successor' }) - writeFileSync(lockPath, JSON.stringify(persistedRecord())) - let raced = false - const lockHooks = hooks({ - processIsAlive: () => { - if (!raced) { - raced = true - // Simulate a contender replacing the stale record and publishing its own lock - // after this process inspected liveness but before it renames the entry. - const displacedPath = `${lockPath}.displaced` - renameSync(lockPath, displacedPath) - writeFileSync(lockPath, JSON.stringify(successor), { flag: 'wx', mode: 0o600 }) - } - return false - } - }) - - expect(() => acquireOrcadInstanceLock(root, lockHooks)).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_held' }) - ) - expect(JSON.parse(readFileSync(lockPath, 'utf8')).nonce).toBe('successor') - }) - it('never reclaims a lock held by a different identity, even a dead one', () => { const root = makeRoot() writeFileSync( join(root, ORCAD_LOCK_FILE_NAME), - JSON.stringify(persistedRecord({ identity: 'uid-2000', nonce: 'other' })) + JSON.stringify({ pid: 424242, identity: 'uid-2000', startedAtMs: 1, nonce: 'other' }) ) expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false }))).toThrow( expect.objectContaining({ code: 'orcad_instance_lock_foreign_identity' }) @@ -140,47 +106,12 @@ describe('acquireOrcadInstanceLock', () => { // A successor reclaimed the root while this process was wedged. writeFileSync( lock.path, - JSON.stringify(persistedRecord({ pid: 777, startedAtMs: 2, nonce: 'successor' })) + JSON.stringify({ pid: 777, identity: 'uid-1000', startedAtMs: 2, nonce: 'successor' }) ) lock.release() expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor') }) - it.each([ - ['invalid JSON', '{'], - ['an incomplete record', JSON.stringify({ pid: 424242, identity: 'uid-1000' })], - ['an invalid pid', JSON.stringify(persistedRecord({ pid: -1 }))], - ['an invalid start time', JSON.stringify({ ...persistedRecord(), startedAtMs: 'yesterday' })] - ])('fails closed when the existing lock contains %s', (_label, contents) => { - const root = makeRoot() - writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), contents) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - expect(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toBe(contents) - }) - - it('fails closed when the existing lock is not a regular file', () => { - const root = makeRoot() - mkdirSync(join(root, ORCAD_LOCK_FILE_NAME)) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - }) - - it('fails closed without reading an oversized lock into memory', () => { - const root = makeRoot() - const lockPath = join(root, ORCAD_LOCK_FILE_NAME) - writeFileSync(lockPath, 'x'.repeat(64 * 1024 + 1)) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - expect(statSync(lockPath).size).toBe(64 * 1024 + 1) - }) - it.runIf(process.platform !== 'win32')( 'tightens a group/world-accessible data root rather than refusing when it can', () => { diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts index 35583526256..052140bcd2e 100644 --- a/src/main/orcad/orcad-instance-lock.ts +++ b/src/main/orcad/orcad-instance-lock.ts @@ -15,8 +15,8 @@ import { randomUUID } from 'node:crypto' import { chmodSync, - linkSync, mkdirSync, + readFileSync, renameSync, statSync, unlinkSync, @@ -25,12 +25,9 @@ import { import { userInfo } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { z } from 'zod' import { getProcessStartedAtMs, startTimeMatches } from '../daemon/daemon-process-start-time' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' export const ORCAD_LOCK_FILE_NAME = 'orcad.lock' -const MAX_ORCAD_LOCK_BYTES = 64 * 1024 export type OrcadInstanceLockCode = | 'orcad_data_root_unusable' @@ -38,7 +35,6 @@ export type OrcadInstanceLockCode = | 'orcad_data_root_shared' | 'orcad_instance_lock_held' | 'orcad_instance_lock_foreign_identity' - | 'orcad_instance_lock_unreadable' export class OrcadInstanceLockError extends Error { constructor( @@ -50,23 +46,16 @@ export class OrcadInstanceLockError extends Error { } } -const OrcadLockRecordSchema = z.object({ - pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +export type OrcadLockRecord = { + pid: number /** Null where the platform cannot read it; PID alone is then the (weaker) fence. */ - startedAtMs: z.number().finite().nonnegative().nullable(), + startedAtMs: number | null /** POSIX uid, or the Windows username. Compared as an opaque string. */ - identity: z.string().min(1).max(1_024), - version: z.string().min(1).max(255), - acquiredAt: z.iso.datetime({ offset: true }), + identity: string + version: string + acquiredAt: string /** Distinguishes our record from a replacement written after we lost the race. */ - nonce: z.string().min(1).max(255) -}) - -export type OrcadLockRecord = z.infer - -/** `null` when absent, unreadable, oversized or malformed; callers must not read that as free. */ -export function readOrcadInstanceLockRecord(path: string): OrcadLockRecord | null { - return parseOrcadInstanceLockRecord(readBoundedLockFile(path) ?? '') + nonce: string } export type OrcadInstanceLock = { @@ -106,11 +95,24 @@ function isErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } -/** `null` for anything that is not a complete record; never a reason to treat a lock as free. */ -export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord | null { +function parseLockRecord(content: string): OrcadLockRecord | null { try { - const result = OrcadLockRecordSchema.safeParse(JSON.parse(content)) - return result.success ? result.data : null + const parsed: unknown = JSON.parse(content) + if (!parsed || typeof parsed !== 'object') { + return null + } + const record = parsed as Partial + if (typeof record.pid !== 'number' || typeof record.identity !== 'string') { + return null + } + return { + pid: record.pid, + startedAtMs: typeof record.startedAtMs === 'number' ? record.startedAtMs : null, + identity: record.identity, + version: typeof record.version === 'string' ? record.version : 'unknown', + acquiredAt: typeof record.acquiredAt === 'string' ? record.acquiredAt : '', + nonce: typeof record.nonce === 'string' ? record.nonce : '' + } } catch { return null } @@ -230,17 +232,8 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } - const existing = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '') - if (!existing) { - // Why fail closed: an unreadable record proves nothing about its holder having exited. - throw new OrcadInstanceLockError( - 'orcad_instance_lock_unreadable', - `The orcad instance lock at ${lockPath} is unreadable, malformed, or larger than ` + - `${MAX_ORCAD_LOCK_BYTES} bytes. Refusing to reclaim it without proof that its holder ` + - 'has exited. Stop orcad and remove the stale lock manually.' - ) - } - if (existing.identity !== identity) { + const existing = parseLockRecord(safeRead(lockPath) ?? '') + if (existing && existing.identity !== identity) { throw new OrcadInstanceLockError( 'orcad_instance_lock_foreign_identity', `The orcad data root ${dataRoot} is locked by identity ${existing.identity} (pid ` + @@ -248,7 +241,7 @@ export function acquireOrcadInstanceLock( 'root corrupts it. Give each its own ORCA_USER_DATA.' ) } - if (isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) { + if (existing && isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) { throw new OrcadInstanceLockError( 'orcad_instance_lock_held', `Another orcad (pid ${existing.pid}, started ${existing.acquiredAt || 'unknown'}) already ` + @@ -256,6 +249,13 @@ export function acquireOrcadInstanceLock( 'ORCA_USER_DATA.' ) } + if (!existing) { + console.warn( + `[orcad] The instance lock at ${lockPath} is unreadable; reclaiming it. If another orcad ` + + 'is running on this data root, stop it now.' + ) + } + // Why rename-and-then-publish rather than unlink-and-write: rename claims one exact // directory entry, so a replacement written between our read and our write stays at the // canonical path and wins — we never delete a record we did not inspect. @@ -269,15 +269,6 @@ export function acquireOrcadInstanceLock( 'holding it. Retry, or stop the other orcad.' ) } - // A contender may have replaced the entry after the liveness check; never displace its record. - const claimedContents = readBoundedLockFile(claimPath) - if (parseOrcadInstanceLockRecord(claimedContents ?? '')?.nonce !== existing.nonce) { - restoreDisplacedLock(claimPath, lockPath, claimedContents) - throw new OrcadInstanceLockError( - 'orcad_instance_lock_held', - `The orcad instance lock at ${lockPath} changed while reclaiming a stale record.` - ) - } if (!publish()) { // Someone else claimed it first. Their record is authoritative; ours is not. try { @@ -298,32 +289,9 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } -/** No-clobber restore: a third contender's newer record at the canonical path stays authoritative. */ -function restoreDisplacedLock( - claimPath: string, - lockPath: string, - claimedContents: string | null -): void { +function safeRead(path: string): string | null { try { - linkSync(claimPath, lockPath) - unlinkSync(claimPath) - } catch { - if (claimedContents === null) { - return - } - try { - writeFileSync(lockPath, claimedContents, { flag: 'wx', mode: 0o600 }) - unlinkSync(claimPath) - } catch { - // A newer contender won, or restoration is unavailable; fail closed. - } - } -} - -function readBoundedLockFile(path: string): string | null { - try { - const { buffer, stats } = readNodeFileSyncWithinLimit(path, MAX_ORCAD_LOCK_BYTES) - return stats.isFile() ? buffer.toString('utf8') : null + return readFileSync(path, 'utf8') } catch { return null } @@ -342,7 +310,7 @@ function makeLock(lockPath: string, record: OrcadLockRecord): OrcadInstanceLock // Why re-read before unlinking: a reclaim by a later orcad (after, say, a SIGKILL that // this process somehow survived enough to run handlers) leaves a record that is not // ours. Deleting it would unlock a live runtime. - const current = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '') + const current = parseLockRecord(safeRead(lockPath) ?? '') if (!current || current.nonce !== record.nonce) { return } diff --git a/src/main/orcad/orcad-lifecycle-host.test.ts b/src/main/orcad/orcad-lifecycle-host.test.ts deleted file mode 100644 index 58004faafa5..00000000000 --- a/src/main/orcad/orcad-lifecycle-host.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./orcad-browser-startup', () => ({ - startOrcadBrowserProvider: () => ({ ready: Promise.resolve(), stop: async () => {} }) -})) - -import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock' -import { startOrcadWithHost } from './orcad-lifecycle' - -const roots: string[] = [] -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function dataRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'orcad-lifecycle-host-')) - roots.push(root) - return root -} - -describe('startOrcadWithHost', () => { - it('names the instance a managed stop must address, and releases its lock on a clean stop', async () => { - const root = dataRoot() - const handle = await startOrcadWithHost( - root, - async () => ({}), - () => {} - ) - expect(readOrcadInstanceLockRecord(handle.instance.lockPath)).toMatchObject({ - pid: handle.instance.pid, - nonce: handle.instance.nonce - }) - await handle.stop() - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(false) - }) - - it('keeps the instance lock when a runtime writer could not be stopped', async () => { - const root = dataRoot() - const failure = new Error('profile writer still running') - const handle = await startOrcadWithHost( - root, - async (registerCleanup) => { - registerCleanup(() => { - throw failure - }) - return {} - }, - () => {} - ) - await expect(handle.stop()).rejects.toBe(failure) - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(true) - }) - - it('keeps the instance lock when a quit handler fails', async () => { - const root = dataRoot() - const handle = await startOrcadWithHost( - root, - async () => ({}), - () => { - throw new AggregateError([new Error('handler')], 'orcad_quit_handlers_failed') - } - ) - await expect(handle.stop()).rejects.toThrow('orcad_quit_handlers_failed') - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(true) - }) -}) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index ffcec0224a3..bfff3b7f14e 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,7 +1,5 @@ import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { startOrcadBrowserProvider } from './orcad-browser-startup' -import { OrcadRuntimeLifetime, type OrcadRuntimeCleanup } from './orcad-runtime-lifetime' -import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { acquireOrcadInstanceLock } from './orcad-instance-lock' import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime' import { resolveOrcadExitCode } from './orcad-exit-code' @@ -23,14 +21,11 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promi export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 -/** - * A launcher and its child can both receive the same process-group or service stop signal. - * Returns the trigger stop-request listeners share, so every source runs one bounded stop. - */ +/** A launcher and its child can both receive the same process-group or service stop signal. */ export function installOrcadShutdownSignals( stop: () => Promise, deadlineMs = ORCAD_SHUTDOWN_DEADLINE_MS -): (reason: string) => void { +): void { let stopping = false const shutdown = (signal: string): void => { if (stopping) { @@ -60,26 +55,26 @@ export function installOrcadShutdownSignals( shutdown('launcher disconnect') } } - return shutdown } export async function startOrcadWithLifecycle( - start: (registerRuntimeCleanup: (cleanup: OrcadRuntimeCleanup) => void) => Promise, + start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise ): Promise }> { - // Runtime resources stop in reverse registration order before any host resource. - const runtime = new OrcadRuntimeLifetime() + let cleanupRuntime = async (): Promise => {} const cleanup = createIdempotentOrcadCleanup(async () => { let runtimeCleanupSucceeded = false try { - await runtime.stop() + await cleanupRuntime() runtimeCleanupSucceeded = true } finally { await cleanupHost(runtimeCleanupSucceeded) } }) try { - const handle = await start((nextCleanup) => runtime.add(nextCleanup)) + const handle = await start((nextCleanup) => { + cleanupRuntime = nextCleanup + }) return { ...handle, stop: cleanup } } catch (error) { try { @@ -92,40 +87,41 @@ export async function startOrcadWithLifecycle( } } -/** Keep profile admission and the instance lock until every runtime writer has stopped. */ +/** Keep profile admission until every runtime writer has stopped. */ export async function startOrcadWithHost( userDataPath: string, - start: (registerCleanup: (cleanup: OrcadRuntimeCleanup) => void) => Promise, + start: (registerCleanup: (cleanup: () => Promise) => void) => Promise, runQuitHandlers: () => void -): Promise; instance: OrcadManagedStopInstance }> { +): Promise }> { const instanceLock = acquireOrcadInstanceLock(userDataPath) - const { pid, startedAtMs, nonce } = instanceLock.record - const instance = { pid, startedAtMs, nonce, lockPath: instanceLock.path } let admission: ProfileStateRuntimeAdmission | undefined - let browserProvider: ReturnType | undefined + let browserProvider: Awaited> | undefined return startOrcadWithLifecycle( async (registerCleanup) => { admission = acquireProfileStateRuntimeAdmission(userDataPath) - // Why not awaited: a desktop sidecar's authorization UI must not hold RPC readiness hostage. - browserProvider = startOrcadBrowserProvider({ userDataPath }) - return { ...(await start(registerCleanup)), instance } + browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) + const provider = browserProvider + setRuntimeBrowserCommandsFactory(provider?.factory ?? null, { + headless: provider !== null, + ...(provider ? { isAvailable: () => provider.isAvailable() } : {}) + }) + return start(registerCleanup) }, async (runtimeCleanupSucceeded) => { - // Failed teardown keeps both fences until the process actually exits. - const host = new OrcadRuntimeLifetime(() => { - if (runtimeCleanupSucceeded) { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runQuitHandlers() + try { + // Failed teardown excludes recovery until the process actually exits. + if (runtimeCleanupSucceeded) { + admission?.release() + } + } finally { instanceLock.release() } - }) - host.add(({ failed }) => { - if (runtimeCleanupSucceeded && !failed) { - admission?.release() - } - }) - host.add(() => runQuitHandlers()) - host.add(() => setRuntimeBrowserCommandsFactory(null)) - host.add(() => browserProvider?.stop()) - await host.stop() + } } ) } diff --git a/src/main/orcad/orcad-managed-stop-admission.ts b/src/main/orcad/orcad-managed-stop-admission.ts deleted file mode 100644 index e354266853e..00000000000 --- a/src/main/orcad/orcad-managed-stop-admission.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' -import { persistOrcadDaemonRetirementRecord } from './orcad-completed-stop-receipt' -import type { OrcadDaemonRetirement } from './orcad-daemon-retirement' - -// Lazy like the rest of orcad's daemon graph: the entry module must not load it at import time. -async function retireLazily(): Promise { - const { retireOrcadDaemonIfIdle } = await import('./orcad-daemon-retirement') - return retireOrcadDaemonIfIdle() -} - -/** Runs before orcad stops for a managed request; it can record an outcome but never veto. */ -export async function prepareOrcadManagedStop( - request: OrcadManagedStopRequest, - retire: () => Promise = retireLazily -): Promise { - if (!request.retireIdleDaemon) { - return - } - const outcome = await retire().catch((error: unknown): OrcadDaemonRetirement => ({ - retirement: 'unverifiable', - liveSessions: null, - reason: `Retirement failed: ${error instanceof Error ? error.message : String(error)}` - })) - try { - persistOrcadDaemonRetirementRecord(request, outcome) - } catch (error) { - // The completion command reads a missing record as `unverifiable`. - console.error('[orcad] could not record daemon retirement:', error) - } -} diff --git a/src/main/orcad/orcad-managed-stop-cancellation.test.ts b/src/main/orcad/orcad-managed-stop-cancellation.test.ts deleted file mode 100644 index 3a19b1eed69..00000000000 --- a/src/main/orcad/orcad-managed-stop-cancellation.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - OrcadManagedStopCancellationSchema, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock } from './orcad-instance-lock' -import { orcadManagedStopRequestPath } from './orcad-managed-stop-request' -import { cancelOrcadManagedStop } from './orcad-managed-stop-cancellation' -import { - claimOrcadManagedStopDecision, - readOrcadManagedStopDecision -} from './orcad-managed-stop-decision' -import { completeOrcadManagedStop } from './orcad-managed-stop-completion' -import { runOrcadManagedStopCancelCommand } from './orcad-managed-stop-command' -import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' - -const roots: string[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function running(): OrcadManagedStopRequest { - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-cancel-')) - roots.push(root) - const lock = acquireOrcadInstanceLock(root, { identity: () => 'uid-1000', startedAtMs: () => 5 }) - const { pid, startedAtMs, nonce } = lock.record - return { - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } -} - -describe('cancelling a managed stop', () => { - it('lets exactly one side decide a transaction', () => { - const request = running() - expect(claimOrcadManagedStopDecision(request, 'canceled')).toBe('canceled') - expect(claimOrcadManagedStopDecision(request, 'dispatched')).toBe('canceled') - expect(readOrcadManagedStopDecision(request)).toBe('canceled') - }) - - it('withdraws a request orcad has not acted on, and removes its file', () => { - const request = running() - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - expect(cancelOrcadManagedStop(request)).toBe('canceled') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('reports dispatched once orcad acted first, and leaves its request in place', () => { - const request = running() - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - expect(claimOrcadManagedStopDecision(request, 'dispatched')).toBe('dispatched') - expect(cancelOrcadManagedStop(request)).toBe('dispatched') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(true) - }) - - it('never removes another transaction pending for the same instance', () => { - const request = running() - const other = { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(other)) - expect(cancelOrcadManagedStop(request)).toBe('canceled') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(true) - }) - - it('does not reissue a cancelled transaction while orcad keeps running', async () => { - const request = running() - cancelOrcadManagedStop(request) - expect( - await completeOrcadManagedStop(request, { - probeProcess: () => 'alive', - startedAtMs: () => 5, - sleep: async () => {} - }) - ).toBe('live') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('keeps orcad running when its listener meets a cancelled request', async () => { - const request = running() - cancelOrcadManagedStop(request) - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-cancel-slot-')) - roots.push(root) - const listener = installOrcadStopRequestListeners(onRequest, { - installRoot: root, - managedStop: { - version: request.version, - runtimeId: request.runtimeId, - instance: request.instance - }, - pollIntervalMs: 10 - }) - await vi.waitFor(() => expect(report).toHaveBeenCalled()) - listener.close() - expect(onRequest).not.toHaveBeenCalled() - expect(String(report.mock.calls[0]?.[1])).toContain('orcad_managed_stop_canceled') - }) - - it('prints one cancellation line through the command', () => { - const request = running() - const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - const cancellation = runOrcadManagedStopCancelCommand([ - ORCAD_CANCEL_MANAGED_STOP_FLAG, - JSON.stringify(request) - ]) - expect(cancellation.outcome).toBe('canceled') - expect( - OrcadManagedStopCancellationSchema.parse(JSON.parse(String(write.mock.calls[0]?.[0]))) - ).toEqual(cancellation) - }) -}) diff --git a/src/main/orcad/orcad-managed-stop-cancellation.ts b/src/main/orcad/orcad-managed-stop-cancellation.ts deleted file mode 100644 index e4e0b468737..00000000000 --- a/src/main/orcad/orcad-managed-stop-cancellation.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** Withdrawing a managed stop request that the running orcad has not acted on yet. */ -import { rmSync } from 'node:fs' -import type { - OrcadManagedStopCancellation, - OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' -import { - orcadManagedStopRequestPath, - readOrcadManagedStopRequest -} from './orcad-managed-stop-request' - -/** `dispatched` means orcad already began stopping; only its completion can say how it ended. */ -export function cancelOrcadManagedStop( - request: OrcadManagedStopRequest -): OrcadManagedStopCancellation['outcome'] { - const outcome = claimOrcadManagedStopDecision(request, 'canceled') - if (outcome === 'canceled') { - removePendingRequest(request) - } - return outcome -} - -/** Removes the request file only while it still carries this transaction. */ -function removePendingRequest(request: OrcadManagedStopRequest): void { - const path = orcadManagedStopRequestPath(request.instance) - try { - if (readOrcadManagedStopRequest(path).transactionId === request.transactionId) { - rmSync(path, { force: true }) - } - } catch { - // Absent or another transaction's request: the standing decision already fences this one. - } -} diff --git a/src/main/orcad/orcad-managed-stop-command.ts b/src/main/orcad/orcad-managed-stop-command.ts deleted file mode 100644 index b62fa2b2311..00000000000 --- a/src/main/orcad/orcad-managed-stop-command.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * `orcad --complete-managed-stop `: one stop, one JSON verdict line on stdout. - * `orcad --cancel-managed-stop `: one cancellation, one JSON outcome line. - * - * Exit 0 means a result was printed — read it, the exit code does not carry it. 64 is a - * malformed invocation; 1 is a failure before any result, which is never evidence of exit. - */ -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG, - OrcadManagedStopRequestSchema, - type OrcadManagedStopCancellation, - type OrcadManagedStopCompletion, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { cancelOrcadManagedStop } from './orcad-managed-stop-cancellation' -import { readOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' -import { ZodError } from 'zod' -import { - completeOrcadManagedStop, - type OrcadManagedStopCompletionOptions -} from './orcad-managed-stop-completion' - -export async function runOrcadManagedStopCommand( - argv: readonly string[], - options: OrcadManagedStopCompletionOptions = {} -): Promise { - const request = parseRequestArgument(argv, ORCAD_COMPLETE_MANAGED_STOP_FLAG) - const verdict = await completeOrcadManagedStop(request, options) - const retirement = - verdict === 'exited' && request.retireIdleDaemon - ? readOrcadCompletedStopReceipt(request)?.retirement - : undefined - const completion: OrcadManagedStopCompletion = { - ...request, - kind: 'orcad_managed_stop_completion', - verdict, - receiptPersisted: verdict === 'exited', - ...(retirement ? { retirement } : {}) - } - process.stdout.write(`${JSON.stringify(completion)}\n`) - return completion -} - -export function runOrcadManagedStopCancelCommand( - argv: readonly string[] -): OrcadManagedStopCancellation { - const request = parseRequestArgument(argv, ORCAD_CANCEL_MANAGED_STOP_FLAG) - const cancellation: OrcadManagedStopCancellation = { - ...request, - kind: 'orcad_managed_stop_cancellation', - outcome: cancelOrcadManagedStop(request) - } - process.stdout.write(`${JSON.stringify(cancellation)}\n`) - return cancellation -} - -function parseRequestArgument(argv: readonly string[], flag: string): OrcadManagedStopRequest { - if (argv.length !== 2 || argv[0] !== flag || !argv[1]) { - throw new Error('orcad_managed_stop_invalid_arguments') - } - return OrcadManagedStopRequestSchema.parse(JSON.parse(argv[1])) -} - -export const ORCAD_MANAGED_STOP_EXIT_VERDICT = 0 -export const ORCAD_MANAGED_STOP_EXIT_FAILED = 1 -export const ORCAD_MANAGED_STOP_EXIT_USAGE = 64 - -export async function runOrcadManagedStopCommandAndExit(argv: readonly string[]): Promise { - let code = ORCAD_MANAGED_STOP_EXIT_VERDICT - try { - if (argv[0] === ORCAD_CANCEL_MANAGED_STOP_FLAG) { - runOrcadManagedStopCancelCommand(argv) - } else { - await runOrcadManagedStopCommand(argv) - } - } catch (error) { - console.error('orcad: managed stop failed:', error) - code = - error instanceof ZodError || - error instanceof SyntaxError || - (error instanceof Error && error.message === 'orcad_managed_stop_invalid_arguments') - ? ORCAD_MANAGED_STOP_EXIT_USAGE - : ORCAD_MANAGED_STOP_EXIT_FAILED - } - // Why exit after the write drains: the caller reads stdout to EOF. - process.stdout.write('', () => process.exit(code)) -} diff --git a/src/main/orcad/orcad-managed-stop-completion.ts b/src/main/orcad/orcad-managed-stop-completion.ts deleted file mode 100644 index 0327e4772dc..00000000000 --- a/src/main/orcad/orcad-managed-stop-completion.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Asking one orcad instance to stop and proving that it did. - * - * Runs as a short-lived command on the execution host. It never signals: it writes the - * instance-bound request the running orcad watches for, then observes until the instance is - * gone. `exited` needs proof — no process with that PID, or a PID whose start time shows it now - * belongs to another process. Anything the host cannot answer is `unverifiable`, never exit. - */ -import { setTimeout as delay } from 'node:timers/promises' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - OrcadManagedStopRequestSchema, - type OrcadManagedStopInstance, - type OrcadManagedStopRequest, - type OrcadManagedStopVerdict -} from '../../shared/orcad-stop-request' -import { - getProcessStartedAtMs, - START_TIME_TOLERANCE_MS, - startTimesWithinTolerance -} from '../daemon/daemon-process-start-time' -import { persistOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' -import { readOrcadManagedStopDecision } from './orcad-managed-stop-decision' -import { - orcadInstanceLockNames, - orcadManagedStopRequestPath, - readOrcadManagedStopRequest -} from './orcad-managed-stop-request' - -export type OrcadProcessProbe = (pid: number) => 'alive' | 'missing' | 'unverifiable' - -export type OrcadManagedStopCompletionOptions = { - probeProcess?: OrcadProcessProbe - startedAtMs?: (pid: number) => number | null - sleep?: () => Promise - attempts?: number - now?: () => Date -} - -function defaultProbe(pid: number): ReturnType { - try { - process.kill(pid, 0) - return 'alive' - } catch (error) { - const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null - // EPERM proves some process holds the PID; it cannot prove ours exited. - return code === 'ESRCH' ? 'missing' : 'unverifiable' - } -} - -function observeInstance( - instance: OrcadManagedStopInstance, - options: OrcadManagedStopCompletionOptions -): OrcadManagedStopVerdict { - const probe = (options.probeProcess ?? defaultProbe)(instance.pid) - if (probe !== 'alive') { - return probe === 'missing' ? 'exited' : 'unverifiable' - } - const actual = (options.startedAtMs ?? getProcessStartedAtMs)(instance.pid) - // A reused PID is proof of exit only when both start times are known and disagree. - if ( - actual !== null && - instance.startedAtMs !== null && - !startTimesWithinTolerance(actual, instance.startedAtMs, START_TIME_TOLERANCE_MS) - ) { - return 'exited' - } - return 'live' -} - -/** Writes the request only while the lock still names this instance, then waits for exit. */ -export async function completeOrcadManagedStop( - input: OrcadManagedStopRequest, - options: OrcadManagedStopCompletionOptions = {} -): Promise { - const request = OrcadManagedStopRequestSchema.parse(input) - const attempts = options.attempts ?? 80 - if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 240) { - throw new Error('orcad_managed_stop_invalid_attempts') - } - const completed = (): 'exited' => { - persistOrcadCompletedStopReceipt(request, (options.now ?? (() => new Date()))()) - return 'exited' - } - let verdict = observeInstance(request.instance, options) - if (verdict !== 'live') { - return verdict === 'exited' ? completed() : verdict - } - if (!lockStillNamesInstance(request.instance)) { - // The process lives but no longer owns the lock it published: it is not ours to address. - return 'unverifiable' - } - if (readOrcadManagedStopDecision(request) === 'canceled') { - // A cancelled transaction is never reissued; its orcad keeps running. - return 'live' - } - const requestPath = orcadManagedStopRequestPath(request.instance) - if (!existingRequestMatches(requestPath, request)) { - return 'unverifiable' - } - if (!writeDurableSecureJsonFile(requestPath, request)) { - throw new Error('orcad_managed_stop_request_permissions_unconfirmed') - } - for (let attempt = 0; attempt < attempts; attempt++) { - await (options.sleep ?? (() => delay(250)))() - verdict = observeInstance(request.instance, options) - if (verdict !== 'live') { - return verdict === 'exited' ? completed() : verdict - } - } - return 'live' -} - -function lockStillNamesInstance(instance: OrcadManagedStopInstance): boolean { - try { - return orcadInstanceLockNames(instance) - } catch { - return false - } -} - -/** A request already present must be this one; another transaction's request is not ours. */ -function existingRequestMatches(path: string, request: OrcadManagedStopRequest): boolean { - try { - return JSON.stringify(readOrcadManagedStopRequest(path)) === JSON.stringify(request) - } catch (error) { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' - } -} diff --git a/src/main/orcad/orcad-managed-stop-decision.ts b/src/main/orcad/orcad-managed-stop-decision.ts deleted file mode 100644 index e6b9d3d029e..00000000000 --- a/src/main/orcad/orcad-managed-stop-decision.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Arbitrating a managed request between the orcad acting on it and a client cancelling it. - * - * Both sides race to create one decision file per transaction with a hard link, which fails if - * the file exists. Exactly one wins, and the loser reads the winner's decision, so a cancel can - * never report success for a stop that orcad already began. - */ -import { linkSync, rmSync } from 'node:fs' -import { randomUUID } from 'node:crypto' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - OrcadManagedStopDecisionSchema, - OrcadManagedStopRequestSchema, - type OrcadManagedStopDecision, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { orcadStopReceiptPath, readOrcadStopReceipt } from './orcad-completed-stop-receipt' - -export type OrcadManagedStopDecisionValue = OrcadManagedStopDecision['decision'] - -function isAlreadyDecided(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST' -} - -export function readOrcadManagedStopDecision( - request: OrcadManagedStopRequest -): OrcadManagedStopDecisionValue | null { - return readOrcadStopReceipt(request, 'decision', OrcadManagedStopDecisionSchema)?.decision ?? null -} - -/** Returns the decision that stands: ours if we created it first, otherwise the other side's. */ -export function claimOrcadManagedStopDecision( - input: OrcadManagedStopRequest, - decision: OrcadManagedStopDecisionValue -): OrcadManagedStopDecisionValue { - const request = OrcadManagedStopRequestSchema.parse(input) - const path = orcadStopReceiptPath(request, 'decision') - const staged = `${path}.${process.pid}.${randomUUID()}.staged` - const record: OrcadManagedStopDecision = { - schemaVersion: 1, - kind: 'orcad_managed_stop_decision', - request, - decision - } - if (!writeDurableSecureJsonFile(staged, record)) { - rmSync(staged, { force: true }) - throw new Error('orcad_managed_stop_decision_permissions_unconfirmed') - } - try { - linkSync(staged, path) - return decision - } catch (error) { - if (!isAlreadyDecided(error)) { - throw error - } - const standing = readOrcadManagedStopDecision(request) - if (!standing) { - throw new Error('orcad_managed_stop_decision_unverifiable') - } - return standing - } finally { - rmSync(staged, { force: true }) - } -} diff --git a/src/main/orcad/orcad-managed-stop-request.ts b/src/main/orcad/orcad-managed-stop-request.ts deleted file mode 100644 index ffd7560cae0..00000000000 --- a/src/main/orcad/orcad-managed-stop-request.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** Validating an instance-bound stop request before the running orcad acts on it. */ -import { createHash } from 'node:crypto' -import { lstatSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { - ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES, - ORCAD_MANAGED_STOP_REQUEST_PREFIX, - OrcadManagedStopRequestSchema, - type OrcadManagedStopContext, - type OrcadManagedStopInstance, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { readOrcadInstanceLockRecord } from './orcad-instance-lock' - -/** Keyed by the lock nonce, so a request for a previous instance is never this one's. */ -export function orcadManagedStopRequestPath(instance: OrcadManagedStopInstance): string { - const digest = createHash('sha256').update(instance.nonce).digest('hex') - return join(dirname(instance.lockPath), `${ORCAD_MANAGED_STOP_REQUEST_PREFIX}.${digest}`) -} - -export function readOrcadManagedStopRequest(path: string): OrcadManagedStopRequest { - if (!lstatSync(path).isFile()) { - throw new Error('orcad_managed_stop_request_not_regular_file') - } - const { buffer } = readNodeFileSyncWithinLimit(path, ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES) - return OrcadManagedStopRequestSchema.parse(JSON.parse(buffer.toString('utf8'))) -} - -export function sameOrcadManagedStopInstance( - left: OrcadManagedStopInstance, - right: Pick -): boolean { - return ( - left.pid === right.pid && left.startedAtMs === right.startedAtMs && left.nonce === right.nonce - ) -} - -/** Whether the instance lock still names exactly this instance. */ -export function orcadInstanceLockNames(instance: OrcadManagedStopInstance): boolean { - if (!lstatSync(instance.lockPath).isFile()) { - return false - } - const record = readOrcadInstanceLockRecord(instance.lockPath) - return record !== null && sameOrcadManagedStopInstance(instance, record) -} - -/** Throws unless the request names this running instance and its lock is still ours. */ -export function validateOrcadManagedStopRequest( - context: OrcadManagedStopContext, - requestPath: string -): OrcadManagedStopRequest { - const request = readOrcadManagedStopRequest(requestPath) - if ( - request.version !== context.version || - request.runtimeId !== context.runtimeId || - request.instance.lockPath !== context.instance.lockPath || - !sameOrcadManagedStopInstance(request.instance, context.instance) - ) { - throw new Error('orcad_managed_stop_request_identity_mismatch') - } - if (!orcadInstanceLockNames(context.instance)) { - throw new Error('orcad_managed_stop_instance_lock_changed') - } - return request -} diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts deleted file mode 100644 index c6f19bcf0e8..00000000000 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - OrcadManagedStopCompletionSchema, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock, type OrcadInstanceLock } from './orcad-instance-lock' -import { - orcadManagedStopRequestPath, - validateOrcadManagedStopRequest -} from './orcad-managed-stop-request' -import { - completeOrcadManagedStop, - type OrcadManagedStopCompletionOptions -} from './orcad-managed-stop-completion' -import { - orcadStopReceiptPath, - readOrcadCompletedStopReceipt, - readOrcadDaemonRetirementRecord -} from './orcad-completed-stop-receipt' -import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' -import { runOrcadManagedStopCommand } from './orcad-managed-stop-command' - -const roots: string[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function running(): { lock: OrcadInstanceLock; request: OrcadManagedStopRequest } { - const root = mkdtempSync(join(tmpdir(), 'orcad-managed-stop-')) - roots.push(root) - const lock = acquireOrcadInstanceLock(root, { - identity: () => 'uid-1000', - version: () => '1.0.0', - startedAtMs: () => 5_000 - }) - const { pid, startedAtMs, nonce } = lock.record - return { - lock, - request: { - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } - } -} - -const context = ({ version, runtimeId, instance }: OrcadManagedStopRequest) => ({ - version, - runtimeId, - instance -}) - -function options( - overrides: Partial = {} -): OrcadManagedStopCompletionOptions { - return { - probeProcess: () => 'alive', - startedAtMs: () => 5_000, - sleep: async () => {}, - attempts: 3, - now: () => new Date('2026-10-01T00:00:00.000Z'), - ...overrides - } -} - -describe('managed stop requests', () => { - it('keys the request file to the instance, so a previous instance never matches', () => { - const { request } = running() - const other = { ...request.instance, nonce: 'other' } - expect(orcadManagedStopRequestPath(request.instance)).not.toBe( - orcadManagedStopRequestPath(other) - ) - }) - - it('accepts only a request naming this runtime, version and instance', () => { - const { request } = running() - const path = orcadManagedStopRequestPath(request.instance) - writeFileSync(path, JSON.stringify(request)) - expect(validateOrcadManagedStopRequest(context(request), path)).toEqual(request) - for (const mismatch of [ - { ...request, version: '0.9.0' }, - { ...request, runtimeId: 'runtime-2' }, - { ...request, instance: { ...request.instance, pid: request.instance.pid + 1 } } - ]) { - writeFileSync(path, JSON.stringify(mismatch)) - expect(() => validateOrcadManagedStopRequest(context(request), path)).toThrow( - 'identity_mismatch' - ) - } - }) - - it('refuses a request once the instance lock names another holder', () => { - const { lock, request } = running() - const path = orcadManagedStopRequestPath(request.instance) - writeFileSync(path, JSON.stringify(request)) - writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' })) - expect(() => validateOrcadManagedStopRequest(context(request), path)).toThrow( - 'instance_lock_changed' - ) - }) -}) - -describe('completing a managed stop', () => { - it('writes the request, waits for proven exit, then persists a receipt', async () => { - const { request } = running() - const probes = ['alive', 'alive', 'missing'] as const - let index = 0 - const verdict = await completeOrcadManagedStop( - request, - options({ probeProcess: () => probes[Math.min(index++, probes.length - 1)] }) - ) - expect(verdict).toBe('exited') - expect(JSON.parse(readFileSync(orcadManagedStopRequestPath(request.instance), 'utf8'))).toEqual( - request - ) - expect(readOrcadCompletedStopReceipt(request)).toMatchObject({ - kind: 'orcad_managed_stop_completed', - exitedAt: '2026-10-01T00:00:00.000Z' - }) - }) - - it('treats a reused PID with a different start time as proof of exit', async () => { - const { request } = running() - expect(await completeOrcadManagedStop(request, options({ startedAtMs: () => 99_000 }))).toBe( - 'exited' - ) - }) - - it.each([ - ['an unanswerable probe', { probeProcess: () => 'unverifiable' as const }], - ['a reused PID with no readable start time', { startedAtMs: () => null, attempts: 1 }] - ])('never reports exit for %s', async (_name, overrides) => { - const { request } = running() - const verdict = await completeOrcadManagedStop(request, options(overrides)) - expect(verdict).not.toBe('exited') - expect(existsSync(orcadStopReceiptPath(request, 'completed'))).toBe(false) - }) - - it('reports a still-running instance as live after its attempts', async () => { - const { request } = running() - expect(await completeOrcadManagedStop(request, options())).toBe('live') - }) - - it('does not address a live process that no longer holds the lock it published', async () => { - const { lock, request } = running() - writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' })) - expect(await completeOrcadManagedStop(request, options())).toBe('unverifiable') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('does not overwrite another transaction pending for the same instance', async () => { - const { request } = running() - const pending = { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(pending)) - expect(await completeOrcadManagedStop(request, options())).toBe('unverifiable') - }) - - it('prints one completion line through the command', async () => { - const { request } = running() - const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - const completion = await runOrcadManagedStopCommand( - ['--complete-managed-stop', JSON.stringify(request)], - options({ probeProcess: () => 'missing' }) - ) - expect(completion).toMatchObject({ verdict: 'exited', receiptPersisted: true }) - expect( - OrcadManagedStopCompletionSchema.parse(JSON.parse(String(write.mock.calls[0]?.[0]))) - ).toEqual(completion) - await expect(runOrcadManagedStopCommand(['--complete-managed-stop'])).rejects.toThrow( - 'invalid_arguments' - ) - }) -}) - -describe('managed stops that retire the daemon', () => { - const outcome = (retirement: 'retired' | 'live' | 'unverifiable', liveSessions: number | null) => - vi.fn(async () => ({ retirement, liveSessions, reason: null })) - - it('does not touch the daemon when retirement was not asked for', async () => { - const { request } = running() - const retire = outcome('retired', 0) - await prepareOrcadManagedStop(request, retire) - expect(retire).not.toHaveBeenCalled() - expect(readOrcadDaemonRetirementRecord(request)).toBeNull() - }) - - it.each([ - ['retired', 0], - ['live', 2], - ['unverifiable', null] - ] as const)( - 'still completes the stop and records retirement %s on the receipt', - async (retirement, liveSessions) => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, outcome(retirement, liveSessions)) - expect(readOrcadDaemonRetirementRecord(retireRequest)).toMatchObject({ - retirement, - liveSessions - }) - expect( - await completeOrcadManagedStop(retireRequest, options({ probeProcess: () => 'missing' })) - ).toBe('exited') - expect(readOrcadCompletedStopReceipt(retireRequest)).toMatchObject({ retirement }) - } - ) - - it('records unverifiable when the retirement attempt itself failed', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, async () => { - throw new Error('daemon socket closed') - }) - expect(readOrcadDaemonRetirementRecord(retireRequest)).toMatchObject({ - retirement: 'unverifiable' - }) - }) - - it('reports unverifiable when orcad exited without recording retirement', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await completeOrcadManagedStop(retireRequest, options({ probeProcess: () => 'missing' })) - expect(readOrcadCompletedStopReceipt(retireRequest)).toMatchObject({ - retirement: 'unverifiable' - }) - }) - - it('omits retirement from the receipt of a plain managed stop', async () => { - const { request } = running() - await completeOrcadManagedStop(request, options({ probeProcess: () => 'missing' })) - expect(readOrcadCompletedStopReceipt(request)).not.toHaveProperty('retirement') - }) - - it('reports the recorded retirement in the completion line, so a client need not read files', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, outcome('live', 1)) - vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - expect( - await runOrcadManagedStopCommand( - ['--complete-managed-stop', JSON.stringify(retireRequest)], - options({ probeProcess: () => 'missing' }) - ) - ).toMatchObject({ verdict: 'exited', retirement: 'live' }) - }) -}) diff --git a/src/main/orcad/orcad-migration-manifest-digest.ts b/src/main/orcad/orcad-migration-manifest-digest.ts deleted file mode 100644 index f9031ceffc7..00000000000 --- a/src/main/orcad/orcad-migration-manifest-digest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createHash } from 'node:crypto' -import { - orcadMigrationManifestHashInput, - type OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' - -export function computeOrcadMigrationManifestSha256( - manifest: Omit -): string { - return createHash('sha256').update(orcadMigrationManifestHashInput(manifest)).digest('hex') -} - -export function assertOrcadMigrationManifestDigest(manifest: OrcadMigrationManifest): void { - const { manifestSha256, ...unsigned } = manifest - if (computeOrcadMigrationManifestSha256(unsigned) !== manifestSha256) { - throw new Error('orcad_migration_manifest_digest_mismatch') - } -} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 3360fdfd944..21ad507c016 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -14,7 +14,6 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, - profileStartupErrors: new Array(), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -25,13 +24,7 @@ vi.mock('./orcad-app-paths', () => ({ resolveUserDataPath: () => state.root })) vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) -vi.mock('./orcad-instance-lock', () => ({ - acquireOrcadInstanceLock: () => ({ - path: join(state.root, 'orcad.lock'), - record: { pid: process.pid, startedAtMs: null, nonce: 'headless-instance' }, - release() {} - }) -})) +vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ startOrcadDaemon: async () => {}, stopOrcadDaemon: async () => {} @@ -44,26 +37,20 @@ vi.mock('../ipc/pty', () => ({ getSshPtyProvider: () => null })) vi.mock('./orcad-profile-state-startup', () => ({ - createOrcadProfileStateStartup: async () => { - const error = state.profileStartupErrors.shift() - if (error) { - throw error + createOrcadProfileStateStartup: async () => ({ + store: { + getSettings: () => ({}), + flushFinalOrThrowAsync: async () => {}, + freezeWritesAsync: async () => {} + }, + authority: { + backend: 'sqlite', + classification: 'neither', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: false } - return { - store: { - getSettings: () => ({}), - flushFinalOrThrowAsync: async () => {}, - freezeWritesAsync: async () => {} - }, - authority: { - backend: 'sqlite', - classification: 'neither', - authority_mode: 'sqlite-candidate', - runtime: 'orcad', - migrated: false - } - } - } + }) })) vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths() {}, @@ -87,7 +74,6 @@ vi.mock('../runtime/orca-runtime', () => ({ rehydrateClientHostedBrowserPages() {} async refreshRestoredOrchestrationAuthority() {} async reconcileLegacyWorkerTerminals() {} - async stopLegacyWorkerTerminalRecovery() {} setMobilePushRegistrar( registrar: Parameters[0] ) { @@ -136,7 +122,6 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ afterEach(() => { rmSync(state.root, { recursive: true, force: true }) - state.profileStartupErrors.length = 0 vi.clearAllMocks() }) @@ -194,28 +179,9 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', it('releases admission when host setup fails before a runtime exists', async () => { state.root = mkdtempSync(join(tmpdir(), 'orca-headless-setup-failure-')) - state.profileStartupErrors.push(new Error('profile startup failed')) + state.browserProvider.mockRejectedValueOnce(new Error('browser setup failed')) const { startOrcad } = await import('./orcad-entry') - await expect(startOrcad()).rejects.toThrow('profile startup failed') + await expect(startOrcad()).rejects.toThrow('browser setup failed') expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) - -it('serves RPC without waiting for browser discovery', async () => { - state.root = mkdtempSync(join(tmpdir(), 'orca-headless-browser-pending-')) - let finishDiscovery!: () => void - state.browserProvider.mockReturnValueOnce( - new Promise((resolve) => { - finishDiscovery = () => resolve(null) - }) - ) - const { startOrcad } = await import('./orcad-entry') - const host = await startOrcad({ noPairing: true, json: true }) - expect(host.managedStop).toMatchObject({ - runtimeId: 'headless-runtime', - instance: { pid: process.pid, nonce: 'headless-instance' } - }) - finishDiscovery() - await host.stop() - expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) -}) diff --git a/src/main/orcad/orcad-runtime-lifetime.test.ts b/src/main/orcad/orcad-runtime-lifetime.test.ts deleted file mode 100644 index 110e7d2876b..00000000000 --- a/src/main/orcad/orcad-runtime-lifetime.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { OrcadRuntimeLifetime } from './orcad-runtime-lifetime' - -it('stops in reverse acquisition order and releases the instance lock last', async () => { - const events: string[] = [] - const lifetime = new OrcadRuntimeLifetime(() => { - events.push('lock') - }) - lifetime.add(() => { - events.push('browser') - }) - lifetime.add(async () => { - events.push('daemon') - }) - lifetime.add(() => { - events.push('rpc') - }) - const stopping = lifetime.stop() - expect(lifetime.stop()).toBe(stopping) - expect(() => lifetime.add(() => {})).toThrow('lifetime_stopping') - await stopping - expect(events).toEqual(['rpc', 'daemon', 'browser', 'lock']) - await lifetime.stop() - expect(events).toHaveLength(4) -}) - -it('awaits pending cleanup before stopping dependencies or releasing the lock', async () => { - const release = vi.fn() - const lifetime = new OrcadRuntimeLifetime(release) - const dependency = vi.fn() - let finish!: () => void - lifetime.add(dependency) - lifetime.add( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const stopping = lifetime.stop() - await Promise.resolve() - expect(release).not.toHaveBeenCalled() - expect(dependency).not.toHaveBeenCalled() - finish() - await stopping - expect(dependency).toHaveBeenCalledOnce() - expect(release).toHaveBeenCalledOnce() -}) - -it('attempts every cleanup but retains the lock if any resource cannot stop', async () => { - const release = vi.fn() - const lifetime = new OrcadRuntimeLifetime(release) - const finalCleanup = vi.fn() - const first = new Error('rpc failed') - const second = new Error('browser failed') - lifetime.add(finalCleanup) - lifetime.add(() => { - throw second - }) - lifetime.add(async () => { - throw first - }) - const stopping = lifetime.stop() - await expect(stopping).rejects.toMatchObject({ errors: [first, second] }) - expect(finalCleanup).toHaveBeenCalledOnce() - expect(release).not.toHaveBeenCalled() - expect(lifetime.stop()).toBe(stopping) -}) - -it('tells later cleanups that an earlier one failed, and rethrows a single failure as-is', async () => { - const states: boolean[] = [] - const failure = new Error('profile writer still running') - const lifetime = new OrcadRuntimeLifetime() - lifetime.add(({ failed }) => { - states.push(failed) - }) - lifetime.add(() => { - throw failure - }) - await expect(lifetime.stop()).rejects.toBe(failure) - expect(states).toEqual([true]) -}) diff --git a/src/main/orcad/orcad-runtime-lifetime.ts b/src/main/orcad/orcad-runtime-lifetime.ts deleted file mode 100644 index e73de829323..00000000000 --- a/src/main/orcad/orcad-runtime-lifetime.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type OrcadRuntimeCleanup = (state: { failed: boolean }) => void | Promise - -/** - * Stops runtime resources in reverse acquisition order, then releases profile ownership. - * - * Every cleanup runs even after an earlier one fails, but a failed teardown never releases: - * a writer that may still be running cannot hand the data root to a second orcad. - */ -export class OrcadRuntimeLifetime { - private readonly cleanups: OrcadRuntimeCleanup[] = [] - private stopping?: Promise - - constructor(private readonly release: () => void = () => {}) {} - - add(cleanup: OrcadRuntimeCleanup): void { - if (this.stopping) { - throw new Error('orcad_runtime_lifetime_stopping') - } - this.cleanups.push(cleanup) - } - - stop(): Promise { - this.stopping ??= Promise.resolve().then(async () => { - const errors: unknown[] = [] - for (const cleanup of this.cleanups.splice(0).toReversed()) { - try { - await cleanup({ failed: errors.length > 0 }) - } catch (error) { - errors.push(error) - } - } - if (errors.length === 1) { - throw errors[0] - } - if (errors.length > 1) { - throw new AggregateError(errors, 'orcad_runtime_cleanup_failed') - } - this.release() - }) - return this.stopping - } -} diff --git a/src/main/orcad/orcad-stop-request-listener.test.ts b/src/main/orcad/orcad-stop-request-listener.test.ts deleted file mode 100644 index 2563543cb95..00000000000 --- a/src/main/orcad/orcad-stop-request-listener.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock } from './orcad-instance-lock' -import { orcadManagedStopRequestPath } from './orcad-managed-stop-request' -import { - installOrcadStopRequestListeners, - type OrcadStopRequestListener -} from './orcad-stop-request-listener' - -const roots: string[] = [] -const listeners: OrcadStopRequestListener[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const listener of listeners.splice(0)) { - listener.close() - } - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function directory(): string { - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-listener-')) - roots.push(root) - return root -} - -function managedContext() { - const lock = acquireOrcadInstanceLock(directory(), { identity: () => 'uid-1000' }) - const { pid, startedAtMs, nonce } = lock.record - return { - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } -} - -function listen( - onRequest: () => void, - options: Parameters[1] -) { - const listener = installOrcadStopRequestListeners(onRequest, { pollIntervalMs: 10, ...options }) - listeners.push(listener) - return listener -} - -describe('orcad stop-request listeners', () => { - it('consumes a slot request written before the listener started', () => { - const installRoot = directory() - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - const onRequest = vi.fn() - listen(onRequest, { installRoot }) - expect(onRequest).toHaveBeenCalledOnce() - expect(existsSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) - }) - - it('picks up a slot request written later, once', async () => { - const installRoot = directory() - const onRequest = vi.fn() - listen(onRequest, { installRoot }) - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).toHaveBeenCalledOnce() - }) - - it('stops after preparation even when preparation fails', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { - installRoot: directory(), - managedStop, - beforeManagedStop: async () => { - throw new Error('daemon unreachable') - } - }) - writeFileSync( - orcadManagedStopRequestPath(managedStop.instance), - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - retireIdleDaemon: true, - ...managedStop - }) - ) - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - expect(report).toHaveBeenCalledWith( - '[orcad] managed stop preparation failed:', - expect.any(Error) - ) - }) - - it('stops on a valid managed request and keeps it as evidence', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - listen(onRequest, { installRoot: directory(), managedStop }) - const path = orcadManagedStopRequestPath(managedStop.instance) - writeFileSync( - path, - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop - }) - ) - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - expect(existsSync(path)).toBe(true) - }) - - it('ignores a managed request for another runtime and reports it once', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { installRoot: directory(), managedStop }) - writeFileSync( - orcadManagedStopRequestPath(managedStop.instance), - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop, - runtimeId: 'runtime-2' - }) - ) - await vi.waitFor(() => expect(report).toHaveBeenCalled()) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).not.toHaveBeenCalled() - expect(report).toHaveBeenCalledOnce() - }) - - it('stops watching once closed', async () => { - const installRoot = directory() - const onRequest = vi.fn() - listen(onRequest, { installRoot }).close() - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/orcad/orcad-stop-request-listener.ts b/src/main/orcad/orcad-stop-request-listener.ts deleted file mode 100644 index 1b09eeed3dc..00000000000 --- a/src/main/orcad/orcad-stop-request-listener.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Watching for stop requests addressed to this orcad. - * - * Two listeners share one shutdown: the plain slot request (`.orcad-stop-request` beside - * `orcad.js`, consumed by unlinking it) and the instance-bound managed request in the data - * root, which is validated and kept as evidence until the completion command proves exit. - */ -import { unlinkSync, watch, type FSWatcher } from 'node:fs' -import { basename, dirname, join } from 'node:path' -import { - ORCAD_STOP_REQUEST_FILENAME, - type OrcadManagedStopContext, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { - orcadManagedStopRequestPath, - validateOrcadManagedStopRequest -} from './orcad-managed-stop-request' -import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' - -export type OrcadStopRequestListener = { close(): void } - -const DEFAULT_POLL_INTERVAL_MS = 1_000 - -function isMissingFile(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' -} - -/** `consume` returns true when the request should stop orcad; a missing file throws ENOENT. */ -function listenForRequest( - requestPath: string, - consume: (path: string) => boolean, - onRequest: () => void, - pollIntervalMs: number -): OrcadStopRequestListener { - let closed = false - let lastFailure: string | null = null - const check = (): void => { - if (closed) { - return - } - try { - if (!consume(requestPath)) { - return - } - } catch (error) { - if (!isMissingFile(error)) { - // Polling retries every interval; report each distinct refusal once. - const failure = error instanceof Error ? error.message : String(error) - if (failure !== lastFailure) { - lastFailure = failure - console.error(`[orcad] refused stop request at ${requestPath}:`, error) - } - } - return - } - closed = true - stop() - onRequest() - } - let watcher: FSWatcher | null = null - try { - watcher = watch(dirname(requestPath), (_event, changed) => { - if (!changed || basename(String(changed)) === basename(requestPath)) { - check() - } - }) - watcher.on('error', (error) => console.error('[orcad] stop-request watcher failed:', error)) - watcher.unref() - } catch (error) { - // The poll below still delivers requests; a watcher only makes them prompt. - console.error('[orcad] stop-request watcher could not start:', error) - } - const poll = setInterval(check, pollIntervalMs) - poll.unref() - const stop = (): void => { - watcher?.close() - clearInterval(poll) - } - // Covers a request written before this listener was installed. - check() - return { - close: () => { - closed = true - stop() - } - } -} - -export function installOrcadStopRequestListeners( - onRequest: () => void, - options: { - installRoot: string - managedStop?: OrcadManagedStopContext - /** Runs once for a validated managed request before the stop; it cannot prevent it. */ - beforeManagedStop?: (request: OrcadManagedStopRequest) => Promise - pollIntervalMs?: number - } -): OrcadStopRequestListener { - const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS - const listeners = [ - listenForRequest( - join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME), - (path) => { - unlinkSync(path) - return true - }, - onRequest, - pollIntervalMs - ) - ] - const managedStop = options.managedStop - if (managedStop) { - const prepare = options.beforeManagedStop ?? (async () => {}) - listeners.push( - listenForRequest( - orcadManagedStopRequestPath(managedStop.instance), - (path) => { - const request = validateOrcadManagedStopRequest(managedStop, path) - // A cancelled request is never acted on; keep listening for the next transaction. - if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') { - throw new Error('orcad_managed_stop_canceled') - } - close() - // Preparation is best effort: whatever it reports, the stop proceeds. - void prepare(request) - .catch((error: unknown) => - console.error('[orcad] managed stop preparation failed:', error) - ) - .finally(onRequest) - return false - }, - onRequest, - pollIntervalMs - ) - ) - } - const close = (): void => { - for (const listener of listeners) { - listener.close() - } - } - return { close } -} diff --git a/src/main/orcad/orcad-terminal-census.test.ts b/src/main/orcad/orcad-terminal-census.test.ts deleted file mode 100644 index e8067f8a2b1..00000000000 --- a/src/main/orcad/orcad-terminal-census.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DaemonSessionInfo } from '../daemon/types' -import { collectOrcadTerminalCensus } from './orcad-terminal-census' - -function session(createdAt: number, protocolVersion = 7): DaemonSessionInfo { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only createdAt and protocolVersion. - return { sessionId: `s-${createdAt}`, createdAt, protocolVersion } as DaemonSessionInfo -} - -describe('orcad terminal census', () => { - it('counts live sessions, those since activation, and their single owning protocol', async () => { - await expect( - collectOrcadTerminalCensus(100, async () => [session(50), session(100), session(150)]) - ).resolves.toEqual({ liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 7 }) - }) - - it('reports zero, not unknown, for an answered empty daemon', async () => { - await expect(collectOrcadTerminalCensus(100, async () => [])).resolves.toEqual({ - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: null - }) - }) - - it('leaves the protocol unknown when sessions span daemon generations', async () => { - const census = await collectOrcadTerminalCensus(0, async () => [session(1, 7), session(2, 6)]) - expect(census.daemonProtocolVersion).toBeNull() - expect(census.liveSessions).toBe(2) - }) - - it('leaves the since-activation count unknown when a session has no creation time', async () => { - const census = await collectOrcadTerminalCensus(0, async () => [session(0)]) - expect(census).toMatchObject({ liveSessions: 1, startedSinceActivation: null }) - }) - - it.each([ - ['no inventory', async () => null], - [ - 'a failed inventory', - async () => { - throw new Error('daemon unreachable') - } - ] - ])('reads %s as unverifiable, never as zero', async (_label, list) => { - await expect(collectOrcadTerminalCensus(0, list)).resolves.toEqual({ - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null - }) - }) -}) diff --git a/src/main/orcad/orcad-terminal-census.ts b/src/main/orcad/orcad-terminal-census.ts deleted file mode 100644 index 63fd96e72bd..00000000000 --- a/src/main/orcad/orcad-terminal-census.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** The terminal census a managed orcad reports to the client planning an update or stop. */ -import { listLiveDaemonSessionsWithProtocol } from '../daemon/daemon-provider-state' -import type { DaemonSessionInfo } from '../daemon/types' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' - -const UNVERIFIABLE: OrcadTerminalCensus = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} - -export async function collectOrcadTerminalCensus( - activatedAt: number, - listSessions: () => Promise = listLiveDaemonSessionsWithProtocol -): Promise { - let sessions: DaemonSessionInfo[] | null - try { - sessions = await listSessions() - } catch { - sessions = null - } - if (!sessions) { - return UNVERIFIABLE - } - const timestampsKnown = sessions.every( - (session) => Number.isFinite(session.createdAt) && session.createdAt > 0 - ) - const protocols = new Set(sessions.map((session) => session.protocolVersion)) - const [protocol] = protocols - return { - liveSessions: sessions.length, - startedSinceActivation: timestampsKnown - ? sessions.filter((session) => session.createdAt >= activatedAt).length - : null, - // Why one protocol only: sessions split across daemon generations have no single owner to - // check an incoming build against, so that reads as unverifiable. - daemonProtocolVersion: protocols.size === 1 && protocol !== undefined ? protocol : null - } -} diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index a5a2d557c47..9c3cf23be7b 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -24,7 +24,6 @@ type SessionSnapshotOperationsRuntime = Pick< | 'quitFlushStarted' | 'state' | 'terminalScrollbackSnapshotStorage' - | 'retainedScrollbackRefsByMigrationId' | 'writesFrozen' > diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 437dced8838..70f22e90c5c 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -63,10 +63,6 @@ import { SshLeaseRecoveryOperations, installSshLeaseRecoveryOperationsContext } from './ssh-lease-recovery-operations' -import { - OrcadSourceExportPersistence, - installOrcadSourceExportPersistenceContext -} from '../migrating-orcad-catalog/orcad-source-export' export type StoreDomainOperations = WriteSchedulingOperations & PrimaryStateWriteOperations & @@ -83,7 +79,6 @@ export type StoreDomainOperations = WriteSchedulingOperations & SshProfileOperations & RetiredWorktreeNamePersistence & SshLeaseRecoveryOperations & - OrcadSourceExportPersistence & WriteFlushBarrierOperations export type StoreDomains = { @@ -108,7 +103,6 @@ export type StoreDomains = { sshProfiles: SshProfileOperations retiredWorktreeNames: RetiredWorktreeNamePersistence sshLeases: SshLeaseRecoveryOperations - orcadSourceExport: OrcadSourceExportPersistence } export const STORE_DOMAIN_OPERATION_CLASSES = [ @@ -127,7 +121,6 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ SshProfileOperations, RetiredWorktreeNamePersistence, SshLeaseRecoveryOperations, - OrcadSourceExportPersistence, WriteFlushBarrierOperations ] as const @@ -147,7 +140,6 @@ export function installStoreDomainContexts(target: Store, domains: StoreDomains) installSshProfileOperationsContext(target, domains.sshProfiles) installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) - installOrcadSourceExportPersistenceContext(target, domains.orcadSourceExport) installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) } @@ -204,8 +196,6 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { ptyBindings, sshProfiles, retiredWorktreeNames, - sshLeases, - // Read-only: holds the runtime state and nothing that writes. - orcadSourceExport: new OrcadSourceExportPersistence(runtime) + sshLeases } } diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index 65ba17d74fc..b29f49f95f9 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -44,8 +44,6 @@ export class StoreRuntimeState { automationListProjectionCache: AutomationListProjectionCache | null = null activeViewPreference!: ActiveViewPreference readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage - /** Scrollback refs each in-flight migration export still reads, keyed by migration id. */ - readonly retainedScrollbackRefsByMigrationId = new Map>() writeTimer: ReturnType | null = null pendingWrite: Promise | null = null pendingSnapshotFileWork: Promise | null = null diff --git a/src/main/persistence/loading-store/terminal-session-cleanup.ts b/src/main/persistence/loading-store/terminal-session-cleanup.ts index 7c9b3b10b11..dede6bf8747 100644 --- a/src/main/persistence/loading-store/terminal-session-cleanup.ts +++ b/src/main/persistence/loading-store/terminal-session-cleanup.ts @@ -27,16 +27,14 @@ export function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSess export function deleteRemovedTerminalScrollbackSnapshots( prior: WorkspaceSessionState | undefined, next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage, - /** Refs a pending migration export still reads; they outlive the session row. */ - retainedRefs: ReadonlySet = new Set() + storage?: TerminalScrollbackSnapshotStorage ): void { if (!prior) { return } const nextRefs = collectTerminalScrollbackSnapshotRefs(next) for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { - if (!nextRefs.has(ref) && !retainedRefs.has(ref)) { + if (!nextRefs.has(ref)) { deleteTerminalScrollbackSnapshotSync(ref, storage) } } diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 178f1d833ff..96834ffbcf9 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -104,8 +104,7 @@ export function setLocalWorkspaceSession( deleteRemovedTerminalScrollbackSnapshots( prior, session, - context.runtime.terminalScrollbackSnapshotStorage, - retainedScrollbackRefs(context.runtime) + context.runtime.terminalScrollbackSnapshotStorage ) } context.runtime.state.workspaceSession = session @@ -141,8 +140,7 @@ export function enqueueTerminalScrollbackSnapshotWork( await deleteRemovedTerminalScrollbackSnapshotsAsync( prior, context.runtime.state.workspaceSession, - context.runtime.terminalScrollbackSnapshotStorage, - retainedScrollbackRefs(context.runtime) + context.runtime.terminalScrollbackSnapshotStorage ) } return @@ -161,16 +159,14 @@ export function enqueueTerminalScrollbackSnapshotWork( await deleteRemovedTerminalScrollbackSnapshotsAsync( migrated, current, - context.runtime.terminalScrollbackSnapshotStorage, - retainedScrollbackRefs(context.runtime) + context.runtime.terminalScrollbackSnapshotStorage ) } if (current) { await deleteRemovedTerminalScrollbackSnapshotsAsync( prior, current, - context.runtime.terminalScrollbackSnapshotStorage, - retainedScrollbackRefs(context.runtime) + context.runtime.terminalScrollbackSnapshotStorage ) } }) @@ -184,11 +180,3 @@ export function enqueueTerminalScrollbackSnapshotWork( }) context.runtime.pendingSnapshotFileWork = work } - -function retainedScrollbackRefs(runtime: { - retainedScrollbackRefsByMigrationId: ReadonlyMap> -}): ReadonlySet { - return new Set( - [...runtime.retainedScrollbackRefsByMigrationId.values()].flatMap((refs) => [...refs]) - ) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts deleted file mode 100644 index 9e7811199ec..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-destination-catalog-projection.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** How a source catalog row looks once a local orcad owns it: no SSH connection or host. */ -import type { Repo } from '../../../shared/repo-types' - -export function toOrcadDestinationRepository(source: Repo): Repo { - const destination = structuredClone(source) - delete destination.connectionId - delete destination.executionHostId - return destination -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts deleted file mode 100644 index 8e3efdeefe6..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - type OrcadMigrationManifest -} from '../../../shared/orcad-migration-manifest' -import type { - OrcadMigrationSnapshotChunkRequest, - OrcadMigrationTerminalScrollbackSnapshot -} from '../../../shared/orcad-migration-scrollback' -import { - getTerminalScrollbackSnapshotPath, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' -import { - abortOrcadMigrationSnapshots, - assertOrcadMigrationSnapshotsReady, - commitOrcadMigrationSnapshots, - inspectOrcadMigrationSnapshotUploads, - pruneOrcadMigrationSnapshotStaging, - stageOrcadMigrationSnapshotChunk -} from './orcad-scrollback-snapshot-transfer' - -let root: string -let storage: TerminalScrollbackSnapshotStorage - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orcad-migration-snapshots-')) - storage = { snapshotRoot: join(root, 'terminal-scrollback') } -}) - -afterEach(() => rmSync(root, { recursive: true, force: true })) - -describe('orcad scrollback snapshot transfer', () => { - it('resumes from staged byte length and accepts only exact idempotent retries', () => { - const bytes = Buffer.from('first line\nmultibyte: 🐋\nlast line', 'utf8') - const descriptor = snapshot('1', 'tab-1', 'leaf-1', bytes) - const manifest = migrationManifest('resume', [descriptor]) - const state = stagedState(manifest) - const first = bytes.subarray(0, 11) - - expect(stage(state, manifest, descriptor, 0, first).acknowledgedOffset).toBe(first.length) - expect(inspectOrcadMigrationSnapshotUploads(manifest, storage)?.[0]?.receivedBytes).toBe( - first.length - ) - expect(stage(state, manifest, descriptor, 0, first).acknowledgedOffset).toBe(first.length) - expect(() => stage(state, manifest, descriptor, 0, Buffer.from('different!!'))).toThrow( - 'orcad_migration_snapshot_retry_mismatch' - ) - expect(() => stage(state, manifest, descriptor, first.length + 1, Buffer.from('x'))).toThrow( - 'orcad_migration_snapshot_offset_invalid' - ) - - stage(state, manifest, descriptor, first.length, bytes.subarray(first.length)) - assertOrcadMigrationSnapshotsReady(manifest, storage) - commitOrcadMigrationSnapshots(manifest, storage) - expect(readFinal(descriptor)).toEqual(bytes) - }) - - it('refuses incomplete and digest-mismatched staged bytes', () => { - const bytes = Buffer.from('expected bytes', 'utf8') - const descriptor = snapshot('2', 'tab-2', 'leaf-2', bytes) - const manifest = migrationManifest('refuse', [descriptor]) - const state = stagedState(manifest) - - stage(state, manifest, descriptor, 0, bytes.subarray(0, 4)) - expect(() => assertOrcadMigrationSnapshotsReady(manifest, storage)).toThrow( - 'orcad_migration_snapshot_incomplete' - ) - stage(state, manifest, descriptor, 4, Buffer.alloc(bytes.length - 4, 0x78)) - expect(() => assertOrcadMigrationSnapshotsReady(manifest, storage)).toThrow( - 'orcad_migration_snapshot_digest_mismatch' - ) - expect(readFinal(descriptor)).toBeNull() - }) - - it('recognizes an already-materialized matching final file after restart', () => { - const bytes = Buffer.from('already committed bytes', 'utf8') - const descriptor = snapshot('3', 'tab-3', 'leaf-3', bytes) - const manifest = migrationManifest('materialized', [descriptor]) - stagedState(manifest) - writeFinal(descriptor, bytes) - - expect(inspectOrcadMigrationSnapshotUploads(manifest, storage)?.[0]?.receivedBytes).toBe( - bytes.length - ) - assertOrcadMigrationSnapshotsReady(manifest, storage) - commitOrcadMigrationSnapshots(manifest, storage) - expect(readFinal(descriptor)).toEqual(bytes) - }) - - it('rejects a same-ref content conflict before materializing any snapshot', () => { - const firstBytes = Buffer.from('first snapshot', 'utf8') - const secondBytes = Buffer.from('second snapshot', 'utf8') - const first = snapshot('4', 'tab-4', 'leaf-4', firstBytes) - const second = snapshot('5', 'tab-5', 'leaf-5', secondBytes) - const manifest = migrationManifest('conflict', [first, second]) - const state = stagedState(manifest) - stage(state, manifest, first, 0, firstBytes) - stage(state, manifest, second, 0, secondBytes) - writeFinal(second, Buffer.alloc(secondBytes.length, 0x78)) - - expect(() => commitOrcadMigrationSnapshots(manifest, storage)).toThrow( - `orcad_migration_snapshot_destination_conflict:${second.ref}` - ) - expect(readFinal(first)).toBeNull() - expect(readFinal(second)).toEqual(Buffer.alloc(secondBytes.length, 0x78)) - }) - - it('removes only the selected staging tree and prunes unjournaled trees', () => { - const bytes = Buffer.from('staged bytes', 'utf8') - const firstDescriptor = snapshot('6', 'tab-6', 'leaf-6', bytes) - const secondDescriptor = snapshot('7', 'tab-7', 'leaf-7', bytes) - const first = migrationManifest('abort-first', [firstDescriptor]) - const second = migrationManifest('retain-second', [secondDescriptor]) - const state = stagedState(first, second) - stage(state, first, firstDescriptor, 0, bytes) - stage(state, second, secondDescriptor, 0, bytes) - - abortOrcadMigrationSnapshots(first, storage) - expect(inspectOrcadMigrationSnapshotUploads(first, storage)?.[0]?.receivedBytes).toBe(0) - expect(inspectOrcadMigrationSnapshotUploads(second, storage)?.[0]?.receivedBytes).toBe( - bytes.length - ) - pruneOrcadMigrationSnapshotStaging( - state.filter((entry) => entry.migrationId !== second.migrationId), - storage - ) - expect(inspectOrcadMigrationSnapshotUploads(second, storage)?.[0]?.receivedBytes).toBe(0) - }) -}) - -function snapshot( - suffix: string, - tabId: string, - leafId: string, - bytes: Buffer -): OrcadMigrationTerminalScrollbackSnapshot { - return { - tabId, - leafId, - ref: `v1-${suffix.repeat(32)}`, - sha256: createHash('sha256').update(bytes).digest('hex'), - byteLength: bytes.length - } -} - -function migrationManifest( - migrationId: string, - snapshots: OrcadMigrationTerminalScrollbackSnapshot[] -): OrcadMigrationManifest { - const workspaceSession = getDefaultWorkspaceSession() - workspaceSession.terminalLayoutsByTabId = Object.fromEntries( - snapshots.map((entry) => [ - entry.tabId, - { - root: { type: 'leaf' as const, leafId: entry.leafId }, - activeLeafId: entry.leafId, - expandedLeafId: null, - scrollbackRefsByLeafId: { [entry.leafId]: entry.ref } - } - ]) - ) - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt: '2026-08-30T12:00:00.000Z', - source: { sshTargetId: 'source', sshTargetGeneration: 1, targetLabel: 'Source' }, - payload: { - repositories: [], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - workspaceSession, - terminalScrollbackSnapshots: snapshots - } - }, - manifestSha256: 'a'.repeat(64) - } -} - -/** The manifests the destination importer holds staged. */ -function stagedState(...manifests: OrcadMigrationManifest[]): OrcadMigrationManifest[] { - return manifests -} - -function stage( - state: OrcadMigrationManifest[], - manifest: OrcadMigrationManifest, - descriptor: OrcadMigrationTerminalScrollbackSnapshot, - offset: number, - bytes: Buffer -) { - const request: OrcadMigrationSnapshotChunkRequest = { - migrationId: manifest.migrationId, - manifestSha256: manifest.manifestSha256, - ref: descriptor.ref, - offset, - bytesBase64: bytes.toString('base64') - } - const stagedManifest = state.find((entry) => entry.migrationId === manifest.migrationId) ?? null - return stageOrcadMigrationSnapshotChunk({ stagedManifest, storage, request }) -} - -function writeFinal(descriptor: OrcadMigrationTerminalScrollbackSnapshot, bytes: Buffer): void { - const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) - if (!path) { - throw new Error('expected snapshot path') - } - if (!storage.snapshotRoot) { - throw new Error('expected snapshot root') - } - mkdirSync(storage.snapshotRoot, { recursive: true }) - writeFileSync(path, bytes) -} - -function readFinal(descriptor: OrcadMigrationTerminalScrollbackSnapshot): Buffer | null { - const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) - return path && existsSync(path) ? readFileSync(path) : null -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts b/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts deleted file mode 100644 index 80a7e566b71..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-scrollback-snapshot-transfer.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { createHash } from 'node:crypto' -import { - closeSync, - existsSync, - fsyncSync, - linkSync, - mkdirSync, - openSync, - readFileSync, - readSync, - readdirSync, - rmSync, - statSync, - writeSync -} from 'node:fs' -import { join } from 'node:path' -import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' -import { - decodeOrcadMigrationSnapshotChunk, - type OrcadMigrationSnapshotChunkRequest, - type OrcadMigrationSnapshotChunkResult, - type OrcadMigrationSnapshotUploadState, - type OrcadMigrationTerminalScrollbackSnapshot -} from '../../../shared/orcad-migration-scrollback' -import { syncDirectoryDurablySync } from '../../durable-file-write' -import { - getTerminalScrollbackSnapshotPath, - getTerminalScrollbackSnapshotRoot, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' - -const STAGING_DIRECTORY = '.orcad-migration-staging' - -/** - * Destination side: append one verified chunk to the staging file for a snapshot the staged - * manifest names. `stagedManifest` is the manifest the importer accepted; a request for any - * other migration or digest is refused. Retries of an already-written range must match it. - */ -export function stageOrcadMigrationSnapshotChunk(args: { - stagedManifest: OrcadMigrationManifest | null - storage: TerminalScrollbackSnapshotStorage - request: OrcadMigrationSnapshotChunkRequest -}): OrcadMigrationSnapshotChunkResult { - const manifest = requireStagedManifest(args.stagedManifest, args.request) - const descriptor = requireDescriptor(manifest, args.request.ref) - const bytes = decodeOrcadMigrationSnapshotChunk(args.request.bytesBase64) - const path = stagedSnapshotPath(args.storage, manifest, descriptor) - mkdirSync(stagingDirectory(args.storage, manifest), { recursive: true, mode: 0o700 }) - const descriptorFd = openStagedFile(path) - try { - const size = statSync(path).size - if (args.request.offset > size || args.request.offset + bytes.length > descriptor.byteLength) { - throw new Error('orcad_migration_snapshot_offset_invalid') - } - if (args.request.offset < size) { - if (args.request.offset + bytes.length > size) { - throw new Error('orcad_migration_snapshot_offset_invalid') - } - const existing = Buffer.alloc(bytes.length) - const read = readSync(descriptorFd, existing, 0, existing.length, args.request.offset) - if (read !== bytes.length || !existing.equals(bytes)) { - throw new Error('orcad_migration_snapshot_retry_mismatch') - } - return chunkResult(args.request, size) - } - const written = writeSync(descriptorFd, bytes, 0, bytes.length, args.request.offset) - if (written !== bytes.length) { - throw new Error('orcad_migration_snapshot_write_incomplete') - } - fsyncSync(descriptorFd) - return chunkResult(args.request, size + bytes.length) - } finally { - closeSync(descriptorFd) - } -} - -export function inspectOrcadMigrationSnapshotUploads( - manifest: OrcadMigrationManifest, - storage: TerminalScrollbackSnapshotStorage -): OrcadMigrationSnapshotUploadState[] | undefined { - const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - if (snapshots.length === 0) { - return undefined - } - return snapshots.map((descriptor) => ({ - ...descriptor, - receivedBytes: matchingFinalSnapshot(storage, descriptor) - ? descriptor.byteLength - : stagedSnapshotSize(storage, manifest, descriptor) - })) -} - -export function assertOrcadMigrationSnapshotsReady( - manifest: OrcadMigrationManifest, - storage: TerminalScrollbackSnapshotStorage -): void { - for (const upload of inspectOrcadMigrationSnapshotUploads(manifest, storage) ?? []) { - const finalStatus = inspectFinalSnapshot(storage, upload) - if (finalStatus === 'conflict') { - throw new Error(`orcad_migration_snapshot_destination_conflict:${upload.ref}`) - } - if (upload.receivedBytes !== upload.byteLength) { - throw new Error(`orcad_migration_snapshot_incomplete:${upload.ref}`) - } - const path = - finalStatus === 'matching' - ? getTerminalScrollbackSnapshotPath(upload.ref, storage) - : stagedSnapshotPath(storage, manifest, upload) - if (!path || !fileMatches(path, upload)) { - throw new Error(`orcad_migration_snapshot_digest_mismatch:${upload.ref}`) - } - } -} - -export function commitOrcadMigrationSnapshots( - manifest: OrcadMigrationManifest, - storage: TerminalScrollbackSnapshotStorage -): void { - const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - assertOrcadMigrationSnapshotsReady(manifest, storage) - const root = getTerminalScrollbackSnapshotRoot(storage) - mkdirSync(root, { recursive: true, mode: 0o700 }) - for (const descriptor of snapshots) { - if (matchingFinalSnapshot(storage, descriptor)) { - rmSync(stagedSnapshotPath(storage, manifest, descriptor), { force: true }) - continue - } - const stagedPath = stagedSnapshotPath(storage, manifest, descriptor) - const finalPath = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) - if (!finalPath) { - throw new Error('orcad_migration_snapshot_ref_invalid') - } - try { - linkSync(stagedPath, finalPath) - } catch (error) { - const finalStatus = inspectFinalSnapshot(storage, descriptor) - if (finalStatus === 'conflict') { - throw new Error(`orcad_migration_snapshot_destination_conflict:${descriptor.ref}`) - } - if (finalStatus !== 'matching') { - throw error - } - } - rmSync(stagedPath, { force: true }) - } - syncDirectoryDurablySync(root) - removeStagingDirectory(storage, manifest) -} - -export function abortOrcadMigrationSnapshots( - manifest: OrcadMigrationManifest, - storage: TerminalScrollbackSnapshotStorage -): void { - removeStagingDirectory(storage, manifest) -} - -/** Removes staging for every migration the importer no longer holds staged. */ -export function pruneOrcadMigrationSnapshotStaging( - stagedManifests: readonly OrcadMigrationManifest[], - storage: TerminalScrollbackSnapshotStorage -): void { - const root = join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY) - const retained = new Set(stagedManifests.map(stagingKey)) - let entries: string[] - try { - entries = readdirSync(root) - } catch { - return - } - for (const entry of entries) { - if (/^[a-f0-9]{32}$/.test(entry) && !retained.has(entry)) { - rmSync(join(root, entry), { recursive: true, force: true }) - } - } -} - -function requireStagedManifest( - staged: OrcadMigrationManifest | null, - request: Pick -): OrcadMigrationManifest { - if ( - !staged || - staged.migrationId !== request.migrationId || - staged.manifestSha256 !== request.manifestSha256 - ) { - throw new Error('orcad_migration_snapshot_catalog_not_staged') - } - return staged -} - -function requireDescriptor( - manifest: OrcadMigrationManifest, - ref: string -): OrcadMigrationTerminalScrollbackSnapshot { - const descriptor = manifest.payload.dormantState?.terminalScrollbackSnapshots?.find( - (entry) => entry.ref === ref - ) - if (!descriptor) { - throw new Error('orcad_migration_snapshot_unknown') - } - return descriptor -} - -function stagedSnapshotSize( - storage: TerminalScrollbackSnapshotStorage, - manifest: OrcadMigrationManifest, - descriptor: OrcadMigrationTerminalScrollbackSnapshot -): number { - try { - return Math.min( - statSync(stagedSnapshotPath(storage, manifest, descriptor)).size, - descriptor.byteLength - ) - } catch { - return 0 - } -} - -function matchingFinalSnapshot( - storage: TerminalScrollbackSnapshotStorage, - descriptor: OrcadMigrationTerminalScrollbackSnapshot -): boolean { - return inspectFinalSnapshot(storage, descriptor) === 'matching' -} - -function inspectFinalSnapshot( - storage: TerminalScrollbackSnapshotStorage, - descriptor: OrcadMigrationTerminalScrollbackSnapshot -): 'absent' | 'matching' | 'conflict' { - const path = getTerminalScrollbackSnapshotPath(descriptor.ref, storage) - if (!path || !existsSync(path)) { - return 'absent' - } - return fileMatches(path, descriptor) ? 'matching' : 'conflict' -} - -function fileMatches(path: string, descriptor: OrcadMigrationTerminalScrollbackSnapshot): boolean { - try { - const bytes = readFileSync(path) - return ( - bytes.length === descriptor.byteLength && - createHash('sha256').update(bytes).digest('hex') === descriptor.sha256 - ) - } catch { - return false - } -} - -function openStagedFile(path: string): number { - try { - return openSync(path, 'r+') - } catch { - try { - return openSync(path, 'wx+', 0o600) - } catch { - return openSync(path, 'r+') - } - } -} - -function stagingDirectory( - storage: TerminalScrollbackSnapshotStorage, - manifest: OrcadMigrationManifest -): string { - return join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY, stagingKey(manifest)) -} - -function stagedSnapshotPath( - storage: TerminalScrollbackSnapshotStorage, - manifest: OrcadMigrationManifest, - descriptor: OrcadMigrationTerminalScrollbackSnapshot -): string { - return join(stagingDirectory(storage, manifest), `${descriptor.ref}.${descriptor.sha256}.part`) -} - -function removeStagingDirectory( - storage: TerminalScrollbackSnapshotStorage, - manifest: OrcadMigrationManifest -): void { - const stagingRoot = join(getTerminalScrollbackSnapshotRoot(storage), STAGING_DIRECTORY) - rmSync(stagingDirectory(storage, manifest), { recursive: true, force: true }) - syncDirectoryDurablySync(stagingRoot) -} - -function stagingKey(manifest: OrcadMigrationManifest): string { - return createHash('sha256') - .update(`${manifest.migrationId}\0${manifest.manifestSha256}`) - .digest('hex') - .slice(0, 32) -} - -function chunkResult( - request: OrcadMigrationSnapshotChunkRequest, - acknowledgedOffset: number -): OrcadMigrationSnapshotChunkResult { - return { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset - } -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts deleted file mode 100644 index e71292242d0..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-automation-state.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - isFinalAutomationRunStatus, - type Automation, - type AutomationRun -} from '../../../shared/automations-types' -import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export type OrcadMigrationSourceAutomationInspection = { - automations: Automation[] - automationRuns: AutomationRun[] - blockedAutomationCount: number - blockedRunCount: number -} - -export function collectOrcadMigrationSourceAutomationState( - state: PersistedState, - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload -): OrcadMigrationSourceAutomationInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog }) - const touchedAutomations = state.automations.filter((entry) => - automationTouchesScope(entry, scope) - ) - const touchedAutomationIds = new Set(touchedAutomations.map((entry) => entry.id)) - const touchedRuns = state.automationRuns.filter( - (entry) => touchedAutomationIds.has(entry.automationId) || runTouchesScope(entry, scope) - ) - const runsByAutomationId = Map.groupBy(touchedRuns, (entry) => entry.automationId) - const automationsById = Map.groupBy(touchedAutomations, (entry) => entry.id) - const automations: Automation[] = [] - const automationRuns: AutomationRun[] = [] - let blockedAutomationCount = 0 - let blockedRunCount = 0 - - for (const [automationId, matching] of automationsById) { - const runs = runsByAutomationId.get(automationId) ?? [] - const eligible = - matching.length === 1 && - automationCanTransfer(matching[0], scope) && - runs.every((run) => runCanTransfer(run, scope)) - if (!eligible) { - blockedAutomationCount += matching.length - blockedRunCount += runs.length - continue - } - automations.push(projectAutomationToDestination(matching[0])) - automationRuns.push(...runs.map(projectAutomationRunToDestination)) - } - - for (const [automationId, runs] of runsByAutomationId) { - if (!automationsById.has(automationId)) { - blockedRunCount += runs.length - } - } - automations.sort((left, right) => compareKeys(left.id, right.id)) - automationRuns.sort((left, right) => compareKeys(left.id, right.id)) - return { automations, automationRuns, blockedAutomationCount, blockedRunCount } -} - -function automationTouchesScope(automation: Automation, scope: OrcadMigrationSourceScope): boolean { - return ( - (automation.executionTargetType === 'ssh' && automation.executionTargetId === scope.targetId) || - (scope.targetGeneration !== null && - automation.executionTargetGeneration === scope.targetGeneration) || - scope.repoIds.has(getAutomationRunRepoId(automation)) || - orcadMigrationOwnerMatchesScope(automation.workspaceId, scope) || - contextTouchesScope(automation.runContext, scope) || - contextTouchesScope(automation.sourceContext, scope) - ) -} - -function runTouchesScope(run: AutomationRun, scope: OrcadMigrationSourceScope): boolean { - return ( - orcadMigrationOwnerMatchesScope(run.workspaceId, scope) || - contextTouchesScope(run.runContext, scope) || - contextTouchesScope(run.sourceContext, scope) - ) -} - -function automationCanTransfer(automation: Automation, scope: OrcadMigrationSourceScope): boolean { - return ( - automation.enabled === false && - automation.executionTargetType === 'ssh' && - automation.executionTargetId === scope.targetId && - automation.schedulerOwner === 'ssh_bridge' && - (automation.executionTargetGeneration === undefined || - automation.executionTargetGeneration === scope.targetGeneration) && - scope.repoIds.has(getAutomationRunRepoId(automation)) && - ownerCanTransfer(automation.workspaceId, scope) && - contextCanTransfer(automation.runContext, scope) && - contextCanTransfer(automation.sourceContext, scope) - ) -} - -function runCanTransfer(run: AutomationRun, scope: OrcadMigrationSourceScope): boolean { - return ( - isFinalAutomationRunStatus(run.status) && - ownerCanTransfer(run.workspaceId, scope) && - contextCanTransfer(run.runContext, scope) && - contextCanTransfer(run.sourceContext, scope) - ) -} - -function ownerCanTransfer(value: string | null, scope: OrcadMigrationSourceScope): boolean { - return value === null || orcadMigrationOwnerMatchesScope(value, scope) -} - -function contextTouchesScope( - context: WorkspaceRunContext | TaskSourceContext | null | undefined, - scope: OrcadMigrationSourceScope -): boolean { - return ( - context?.hostId === scope.hostId || - (typeof context?.repoId === 'string' && scope.repoIds.has(context.repoId)) - ) -} - -function contextCanTransfer( - context: WorkspaceRunContext | TaskSourceContext | null | undefined, - scope: OrcadMigrationSourceScope -): boolean { - if (!context) { - return true - } - return ( - context.hostId === scope.hostId && - typeof context.repoId === 'string' && - scope.repoIds.has(context.repoId) && - (!context.projectHostSetupId || context.projectHostSetupId === context.repoId) - ) -} - -function projectAutomationToDestination(source: Automation): Automation { - const destination: Automation = { - ...structuredClone(source), - runContext: projectContextToDestination(source.runContext), - sourceContext: projectContextToDestination(source.sourceContext), - executionTargetType: 'local', - executionTargetId: 'local', - schedulerOwner: 'remote_host_service', - workspaceId: projectOwnerToDestination(source.workspaceId) - } - delete destination.executionTargetGeneration - return destination -} - -function projectAutomationRunToDestination(source: AutomationRun): AutomationRun { - return { - ...structuredClone(source), - runContext: projectContextToDestination(source.runContext), - sourceContext: projectContextToDestination(source.sourceContext), - workspaceId: projectOwnerToDestination(source.workspaceId) - } -} - -function projectContextToDestination( - context: T | null | undefined -): T | null { - if (!context) { - return null - } - return Object.assign(structuredClone(context), { - hostId: LOCAL_EXECUTION_HOST_ID, - projectHostSetupId: context.repoId ?? null - }) -} - -function projectOwnerToDestination(value: string | null): string | null { - return value === null ? null : unqualifyOrcadMigrationOwnerKey(value) -} - -function compareKeys(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts deleted file mode 100644 index fc902a994c5..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-catalog.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migration-manifest' -import type { ProjectGroup } from '../../../shared/project-group-types' -import type { SshTarget } from '../../../shared/ssh-types' -import type { Store } from '../../persistence' - -type OrcadSourceCatalogStore = Pick - -export function collectOrcadMigrationSourceCatalog( - store: OrcadSourceCatalogStore, - target: Pick -): OrcadMigrationCatalogPayload { - const repositories = store - .getRepos() - .filter((repo) => repo.connectionId === target.id) - .map((repo) => structuredClone(repo)) - const allGroups = store.getProjectGroups() - const groupConnectionById = new Map(allGroups.map((group) => [group.id, group.connectionId])) - const folderWorkspaces = store - .getFolderWorkspaces() - .filter( - (workspace) => - (workspace.connectionId ?? groupConnectionById.get(workspace.projectGroupId) ?? null) === - target.id - ) - .map((workspace) => structuredClone(workspace)) - const projectGroups = collectProjectGroups( - allGroups, - new Set([ - ...repositories.flatMap((repo) => (repo.projectGroupId ? [repo.projectGroupId] : [])), - ...folderWorkspaces.map((workspace) => workspace.projectGroupId), - ...allGroups.filter((group) => group.connectionId === target.id).map((group) => group.id) - ]) - ) - return { repositories, projectGroups, folderWorkspaces } -} - -function collectProjectGroups( - groups: ProjectGroup[], - initialIds: ReadonlySet -): ProjectGroup[] { - const byId = new Map(groups.map((group) => [group.id, group])) - const includedIds = new Set(initialIds) - const pending = [...initialIds] - while (pending.length > 0) { - const nextId = pending.pop() - if (!nextId) { - continue - } - const group = byId.get(nextId) - if (!group?.parentGroupId || includedIds.has(group.parentGroupId)) { - continue - } - includedIds.add(group.parentGroupId) - pending.push(group.parentGroupId) - } - return groups.filter((group) => includedIds.has(group.id)).map((group) => structuredClone(group)) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts deleted file mode 100644 index b139d31d17c..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-browser-intents.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { OrcadMigrationClientHostedBrowserCloseIntent } from '../../../shared/orcad-migration-client-state' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export function collectCloseIntents( - state: PersistedState, - scope: OrcadMigrationSourceScope, - destinationEnvironmentId: string | undefined, - eligibleSession: WorkspaceSessionState | undefined, - onBlocked: () => void -): OrcadMigrationClientHostedBrowserCloseIntent[] | undefined { - const eligiblePages = new Set() - for (const [ownerKey, pages] of Object.entries( - eligibleSession?.clientHostedBrowserPagesByWorktree ?? {} - )) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - const worktreeId = unqualifyOrcadMigrationOwnerKey(ownerKey) - for (const page of pages) { - eligiblePages.add(`${worktreeId}\0${page.browserPageId}`) - } - } - const result: OrcadMigrationClientHostedBrowserCloseIntent[] = [] - const seen = new Set() - const sessions = [ - state.workspaceSession, - ...Object.values(state.workspaceSessionsByHostId ?? {}).flatMap((entry) => - entry ? [entry] : [] - ) - ].filter((entry): entry is WorkspaceSessionState => Boolean(entry)) - for (const session of sessions) { - for (const [sourceEnvironmentId, intents] of Object.entries( - session.clientHostedBrowserCloseIntentsByEnvironment ?? {} - )) { - // Intents already keyed to the destination were handled by a prior retry. - if (sourceEnvironmentId === destinationEnvironmentId) { - continue - } - for (const intent of intents) { - if (!orcadMigrationOwnerMatchesScope(intent.worktreeId, scope)) { - continue - } - const worktreeId = unqualifyOrcadMigrationOwnerKey(intent.worktreeId) - const key = `${sourceEnvironmentId}\0${intent.browserPageId}\0${worktreeId}` - if ( - !eligiblePages.has(`${worktreeId}\0${intent.browserPageId}`) || - !destinationEnvironmentId - ) { - onBlocked() - continue - } - if (seen.has(key)) { - onBlocked() - continue - } - seen.add(key) - result.push({ - sourceEnvironmentId, - browserPageId: intent.browserPageId, - worktreeId, - closedAt: intent.closedAt - }) - } - } - } - return result.length > 0 ? result : undefined -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts deleted file mode 100644 index 8f3abb11ea6..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { OrcadMigrationCatalogPayload } from '../../../shared/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' -import type { TerminalTab } from '../../../shared/terminal-tab-types' -import type { BrowserWorkspace } from '../../../shared/browser-workspace-types' -import { - CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - type PersistedClientHostedBrowserPage -} from '../../../shared/client-hosted-browser-page-record' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { collectOrcadMigrationSourceClientState } from './orcad-source-client-state' - -const source = { - sshTargetId: 'target-1', - sshTargetGeneration: 3, - targetLabel: 'Build host' -} -const catalog: OrcadMigrationCatalogPayload = { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1, - connectionId: source.sshTargetId, - executionHostId: 'ssh:target-1' - } - ], - projectGroups: [], - folderWorkspaces: [] -} - -function state(): PersistedState { - const persisted = getDefaultPersistedState('/home/test') - persisted.sshTargets = [ - { - id: source.sshTargetId, - label: source.targetLabel, - host: 'source.example.com', - port: 22, - username: 'deploy', - portForwards: [] - } - ] - return persisted -} - -function terminalTab(id: string, worktreeId: string): TerminalTab { - return { - id, - ptyId: null, - worktreeId, - title: id, - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } -} - -function session( - fields: Pick & Partial -): WorkspaceSessionState { - return { ...getDefaultWorkspaceSession(), tabGroups: {}, ...fields } -} - -function browserWorkspace(id: string, worktreeId: string): BrowserWorkspace { - return { - id, - worktreeId, - url: 'about:blank', - title: id, - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } -} - -function hostedPage(browserPageId: string, workspaceId: string): PersistedClientHostedBrowserPage { - return { - v: CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - browserPageId, - workspaceId, - browserProfileId: 'default', - url: 'about:blank', - title: browserPageId, - pairedDeviceId: 'device-1', - savedAt: 1 - } -} - -describe('source client-state migration', () => { - it('rekeys representable mobile selections and desktop routing', () => { - const current = state() - current.mobileClientTabSelectionsByDeviceId = { - phone: { - 'ssh:target-1|repo-1::/srv/worktree': { - activeTabId: 'tab-1', - activeGroupId: null, - activeTabIdByGroupId: {} - } - } - } - current.ui.lastActiveRepoId = 'repo-1' - current.ui.lastActiveWorktreeId = 'ssh:target-1|repo-1::/srv/worktree' - current.ui.workspaceHostScope = 'ssh:target-1' - current.ui.showDotfilesByWorktree = { - 'ssh:target-1|repo-1::/srv/worktree': false - } - const scoped = session({ - tabsByWorktree: { - 'ssh:target-1|repo-1::/srv/worktree': [ - terminalTab('tab-1', 'ssh:target-1|repo-1::/srv/worktree') - ] - }, - tabGroups: {} - }) - - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - 'environment-1', - scoped - ) - expect(result.blockedCounts).toEqual({ - 'mobile-tab-selection': 0, - 'ui-routing': 0, - 'saved-port-forward': 0 - }) - expect(result.payload?.mobileClientTabSelectionsByDeviceId).toMatchObject({ - phone: { 'repo-1::/srv/worktree': { activeTabId: 'tab-1' } } - }) - expect(result.payload?.uiRouting).toMatchObject({ - lastActiveRepoId: 'repo-1', - lastActiveWorktreeId: 'repo-1::/srv/worktree', - workspaceHostScope: 'local', - showDotfilesByWorktree: { 'repo-1::/srv/worktree': false } - }) - }) - - it('blocks a mobile selection that points at a group outside the migrated owner', () => { - const current = state() - const sourceOwner = 'ssh:target-1|repo-1::/srv/worktree' - const unrelatedOwner = 'ssh:target-1|repo-2::/srv/other' - current.mobileClientTabSelectionsByDeviceId = { - phone: { - [sourceOwner]: { - activeTabId: null, - activeGroupId: 'group-unrelated', - activeTabIdByGroupId: {} - } - } - } - const scoped = session({ - tabsByWorktree: {}, - tabGroups: { - [unrelatedOwner]: [ - { - id: 'group-unrelated', - worktreeId: unrelatedOwner, - activeTabId: null, - tabOrder: [] - } - ] - } - }) - - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - 'environment-1', - scoped - ) - - expect(result.payload?.mobileClientTabSelectionsByDeviceId).toBeUndefined() - expect(result.blockedCounts['mobile-tab-selection']).toBe(1) - }) - - it('blocks a per-group tab selection whose group is outside the migrated owner', () => { - const current = state() - const sourceOwner = 'ssh:target-1|repo-1::/srv/worktree' - const unrelatedOwner = 'ssh:target-1|repo-2::/srv/other' - current.mobileClientTabSelectionsByDeviceId = { - phone: { - [sourceOwner]: { - activeTabId: null, - activeGroupId: null, - activeTabIdByGroupId: { 'group-unrelated': 'tab-source' } - } - } - } - const scoped = session({ - tabsByWorktree: { - [sourceOwner]: [terminalTab('tab-source', sourceOwner)] - }, - tabGroups: { - [unrelatedOwner]: [ - { - id: 'group-unrelated', - worktreeId: unrelatedOwner, - activeTabId: 'tab-source', - tabOrder: ['tab-source'] - } - ] - } - }) - - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - 'environment-1', - scoped - ) - - expect(result.payload?.mobileClientTabSelectionsByDeviceId).toBeUndefined() - expect(result.blockedCounts['mobile-tab-selection']).toBe(1) - }) - - it('captures saved forwards and reports duplicate local ports', () => { - const current = state() - current.sshTargets[0].portForwards = [ - { localPort: 9000, remoteHost: '127.0.0.1', remotePort: 6768 }, - { localPort: 9000, remoteHost: '127.0.0.1', remotePort: 6769 } - ] - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - undefined, - undefined - ) - expect(result.payload?.savedPortForwards).toHaveLength(2) - expect(result.blockedCounts['saved-port-forward']).toBe(1) - }) - - it('captures only close intents for transferred client-hosted pages', () => { - const current = state() - const worktreeId = 'ssh:target-1|repo-1::/srv/worktree' - current.workspaceSession = session({ - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - browserTabsByWorktree: { - [worktreeId]: [browserWorkspace('browser-1', worktreeId)] - }, - clientHostedBrowserPagesByWorktree: { - [worktreeId]: [hostedPage('page-1', 'browser-1')] - }, - clientHostedBrowserCloseIntentsByEnvironment: { - 'old-environment': [ - { browserPageId: 'page-1', worktreeId, closedAt: 42 }, - { browserPageId: 'unrelated-page', worktreeId, closedAt: 43 } - ], - 'environment-1': [{ browserPageId: 'destination-page', worktreeId, closedAt: 44 }] - } - }) - const result = collectOrcadMigrationSourceClientState( - current, - source, - catalog, - 'environment-1', - current.workspaceSession - ) - - expect(result.blockedCount).toBe(1) - expect(result.payload?.clientHostedBrowserCloseIntents).toEqual([ - { - sourceEnvironmentId: 'old-environment', - browserPageId: 'page-1', - worktreeId: 'repo-1::/srv/worktree', - closedAt: 42 - } - ]) - }) -}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts deleted file mode 100644 index e01be755087..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { hostStableKey } from '../../../shared/automation-owner-key' -import { parsePersistedAutomationHostFilter } from '../../../shared/automation-host-filter' -import type { - OrcadMigrationClientStatePayload, - OrcadMigrationUiRoutingState -} from '../../../shared/orcad-migration-client-state' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import type { - PersistedMobileClientTabSelection, - PersistedState -} from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { orcadMigrationPaneBelongsToTabs } from './orcad-source-session-dependencies' -import { collectCloseIntents } from './orcad-source-client-browser-intents' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey -} from './orcad-source-scope' - -export type OrcadMigrationSourceClientStateInspection = { - payload: OrcadMigrationClientStatePayload | undefined - /** Invalid or unmatched closes are folded into the existing workspace-session blocker. */ - blockedCount: number - blockedCounts: { - 'mobile-tab-selection': number - 'ui-routing': number - 'saved-port-forward': number - } -} - -export function collectOrcadMigrationSourceClientState( - state: PersistedState, - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload, - destinationEnvironmentId: string | undefined, - eligibleSession: WorkspaceSessionState | undefined -): OrcadMigrationSourceClientStateInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog }) - const blockedCounts = { - 'mobile-tab-selection': 0, - 'ui-routing': 0, - 'saved-port-forward': 0 - } - let blockedCount = 0 - const mobile = collectMobileSelections(state, scope, eligibleSession, blockedCounts) - const uiRouting = collectUiRouting( - state, - scope, - destinationEnvironmentId, - eligibleSession, - blockedCounts - ) - const target = state.sshTargets.find((entry) => entry.id === scope.targetId) - const savedPortForwards = target?.portForwards ? structuredClone(target.portForwards) : undefined - if (savedPortForwards) { - const ports = new Set() - for (const forward of savedPortForwards) { - if (ports.has(forward.localPort)) { - blockedCounts['saved-port-forward'] += 1 - } - ports.add(forward.localPort) - } - } - const closeIntents = collectCloseIntents( - state, - scope, - destinationEnvironmentId, - eligibleSession, - () => { - blockedCount += 1 - } - ) - const clientState: OrcadMigrationClientStatePayload = { - ...(mobile && Object.keys(mobile).length > 0 - ? { mobileClientTabSelectionsByDeviceId: mobile } - : {}), - ...(uiRouting && Object.keys(uiRouting).length > 0 ? { uiRouting } : {}), - ...(savedPortForwards && savedPortForwards.length > 0 ? { savedPortForwards } : {}), - ...(closeIntents && closeIntents.length > 0 - ? { clientHostedBrowserCloseIntents: closeIntents } - : {}) - } - return { - payload: Object.keys(clientState).length > 0 ? clientState : undefined, - blockedCount, - blockedCounts - } -} - -function collectMobileSelections( - state: PersistedState, - scope: ReturnType, - session: WorkspaceSessionState | undefined, - blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts'] -): - | NonNullable - | undefined { - const eligible = session ? collectEligibleSessionIdentity(session, scope) : null - const result: NonNullable< - OrcadMigrationClientStatePayload['mobileClientTabSelectionsByDeviceId'] - > = {} - const destinationKeys = new Set() - for (const [deviceId, selections] of Object.entries( - state.mobileClientTabSelectionsByDeviceId ?? {} - )) { - const projected: Record = {} - for (const [ownerKey, selection] of Object.entries(selections)) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - const destinationKey = unqualifyOrcadMigrationOwnerKey(ownerKey) - if (destinationKeys.has(`${deviceId}\0${destinationKey}`)) { - blockedCounts['mobile-tab-selection'] += 1 - continue - } - if (!mobileSelectionIsRepresentable(selection, eligible)) { - blockedCounts['mobile-tab-selection'] += 1 - continue - } - destinationKeys.add(`${deviceId}\0${destinationKey}`) - projected[destinationKey] = structuredClone(selection) - } - if (Object.keys(projected).length > 0) { - result[deviceId] = projected - } - } - return Object.keys(result).length > 0 ? result : undefined -} - -function collectUiRouting( - state: PersistedState, - scope: ReturnType, - destinationEnvironmentId: string | undefined, - session: WorkspaceSessionState | undefined, - blockedCounts: OrcadMigrationSourceClientStateInspection['blockedCounts'] -): OrcadMigrationUiRoutingState | undefined { - const ui = state.ui - const result: OrcadMigrationUiRoutingState = {} - if (ui.lastActiveRepoId && scope.repoIds.has(ui.lastActiveRepoId)) { - result.lastActiveRepoId = ui.lastActiveRepoId - } - if (ui.lastActiveWorktreeId && orcadMigrationOwnerMatchesScope(ui.lastActiveWorktreeId, scope)) { - result.lastActiveWorktreeId = unqualifyOrcadMigrationOwnerKey(ui.lastActiveWorktreeId) - } - const filterRepoIds = ui.filterRepoIds.filter((repoId) => scope.repoIds.has(repoId)) - if (filterRepoIds.length > 0) { - result.filterRepoIds = filterRepoIds - } - const dotfiles = Object.entries(ui.showDotfilesByWorktree ?? {}) - .filter(([ownerKey]) => orcadMigrationOwnerMatchesScope(ownerKey, scope)) - .map(([ownerKey, enabled]) => [unqualifyOrcadMigrationOwnerKey(ownerKey), enabled] as const) - if (dotfiles.length > 0) { - result.showDotfilesByWorktree = Object.fromEntries(dotfiles) - } - const dismissed = (ui.setupScriptPromptDismissedRepoIds ?? []).filter((repoId) => - scope.repoIds.has(repoId) - ) - if (dismissed.length > 0) { - result.setupScriptPromptDismissedRepoIds = dismissed - } - const manualOrder = (ui.manualRepoOrder ?? []) - .filter((entry) => entry.hostId === scope.hostId && scope.repoIds.has(entry.repoId)) - .map((entry) => ({ hostId: 'local' as const, repoId: entry.repoId })) - if (manualOrder.length > 0) { - result.manualRepoOrder = manualOrder - } - if (ui.workspaceHostScope === scope.hostId) { - result.workspaceHostScope = 'local' - } - const visibleHosts = (ui.visibleWorkspaceHostIds ?? []).filter( - (hostId) => hostId === scope.hostId - ) - if (visibleHosts.length > 0) { - result.visibleWorkspaceHostIds = ['local'] - } - const hostOrder = (ui.workspaceHostOrder ?? []).filter((hostId) => hostId === scope.hostId) - if (hostOrder.length > 0) { - result.workspaceHostOrder = ['local'] - } - const filter = parsePersistedAutomationHostFilter(ui.automationHostFilter) - if ( - filter.kind === 'host' && - hostStableKey(filter.host) === `host:desktop:ssh:${encodeURIComponent(scope.targetId)}` - ) { - result.automationHostFilter = destinationEnvironmentId - ? { - kind: 'host', - hostKey: hostStableKey({ - authority: { kind: 'runtime', environmentId: destinationEnvironmentId }, - selector: { kind: 'self' } - }) - } - : { kind: 'host', hostKey: 'host:desktop:self' } - } - const eligible = session ? collectEligibleSessionIdentity(session, scope) : null - const acknowledgements = Object.entries(ui.acknowledgedAgentsByPaneKey ?? {}).filter( - ([paneKey]) => { - const allowed = eligible ? orcadMigrationPaneBelongsToTabs(paneKey, eligible.tabIds) : false - if (!allowed) { - blockedCounts['ui-routing'] += 1 - } - return allowed - } - ) - if (acknowledgements.length > 0) { - result.acknowledgedAgentsByPaneKey = Object.fromEntries(acknowledgements) - } - return Object.keys(result).length > 0 ? result : undefined -} - -type EligibleSessionIdentity = { tabIds: ReadonlySet; groupIds: ReadonlySet } - -function collectEligibleSessionIdentity( - session: WorkspaceSessionState, - scope: ReturnType -): EligibleSessionIdentity { - const tabIds = new Set() - const groupIds = new Set() - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - tabs.forEach((tab) => tabIds.add(tab.id)) - } - for (const [ownerKey, groups] of Object.entries(session.tabGroups ?? {})) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - groups.forEach((group) => groupIds.add(group.id)) - } - return { tabIds, groupIds } -} - -function mobileSelectionIsRepresentable( - selection: PersistedMobileClientTabSelection, - eligible: EligibleSessionIdentity | null -): boolean { - if (!eligible) { - return ( - selection.activeTabId === null && - selection.activeGroupId === null && - Object.keys(selection.activeTabIdByGroupId).length === 0 - ) - } - if (selection.activeTabId !== null && !eligible.tabIds.has(selection.activeTabId)) { - return false - } - if (selection.activeGroupId !== null && !eligible.groupIds.has(selection.activeGroupId)) { - return false - } - return Object.entries(selection.activeTabIdByGroupId).every( - ([groupId, tabId]) => eligible.groupIds.has(groupId) && eligible.tabIds.has(tabId) - ) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts deleted file mode 100644 index 49cb049057f..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultPersistedState } from '../../../shared/constants' -import type { Automation, AutomationRun } from '../../../shared/automations-types' -import type { FolderWorkspace } from '../../../shared/folder-workspace-types' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - type OrcadMigrationManifest -} from '../../../shared/orcad-migration-manifest' -import type { Repo } from '../../../shared/repo-types' -import type { SshTarget } from '../../../shared/ssh-types' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import { getRemoteRetirementNamespaceKey } from '../../worktree-name-retirement' -import { collectOrcadMigrationSourceDependencyCensus } from './orcad-source-dependency-census' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 8 -} - -const REPO: Repo = { - id: 'repo-1', - path: '/srv/repo', - displayName: 'Repository', - badgeColor: '#737373', - addedAt: 1, - connectionId: TARGET.id, - executionHostId: `ssh:${TARGET.id}`, - projectGroupId: 'group-1' -} - -const FOLDER: FolderWorkspace = { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Folder', - folderPath: '/srv/folder', - connectionId: TARGET.id, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 -} - -const MANIFEST: OrcadMigrationManifest = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-1', - createdAt: '2026-08-30T12:00:00.000Z', - source: { - sshTargetId: TARGET.id, - sshTargetGeneration: TARGET.generation ?? null, - targetLabel: TARGET.label - }, - payload: { - repositories: [REPO], - projectGroups: [], - folderWorkspaces: [FOLDER] - }, - manifestSha256: 'a'.repeat(64) -} - -describe('orcad migration source dependency census', () => { - it('allows a static catalog with no unrepresented dependent state', () => { - const state = getDefaultPersistedState('/home/test') - state.sshTargets = [TARGET] - state.repos = [REPO] - state.folderWorkspaces = [FOLDER] - - expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toMatchObject({ - totalCount: 0 - }) - }) - - it('counts every currently unrepresented ownership surface before remote mutation', () => { - const state = getDefaultPersistedState('/home/test') - const worktreeId = `${REPO.id}::/srv/worktree` - const folderKey = folderWorkspaceKey(FOLDER.id) - state.sshTargets = [ - { - ...TARGET, - portForwards: [{ localPort: 3000, remoteHost: '127.0.0.1', remotePort: 3000 }] - } - ] - state.repos = [REPO] - state.folderWorkspaces = [FOLDER] - state.sshRemotePtyLeases = [ - { - targetId: TARGET.id, - ptyId: 'pty-1', - worktreeId, - state: 'detached', - createdAt: 1, - updatedAt: 1 - } - ] - state.sshPtyConsumerRecoveries = [ - { - targetId: TARGET.id, - clientInstanceId: 'client-1', - serverBuildId: 'build-1', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'lease-1' - } - ] - state.workspaceSessionsByHostId = { - [`ssh:${TARGET.id}`]: { - ...state.workspaceSession, - tabsByWorktree: { - [worktreeId]: [ - { - id: 'tab-1', - ptyId: 'pty-1', - worktreeId, - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - } - state.worktreeMeta[worktreeId] = { - displayName: 'Worktree', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - hostId: `ssh:${TARGET.id}` - } - state.worktreeLineageById[worktreeId] = { - worktreeId, - worktreeInstanceId: 'instance-1', - parentWorktreeId: REPO.id, - parentWorktreeInstanceId: 'instance-parent', - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 1 - } - state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] = { - childWorkspaceKey: worktreeWorkspaceKey(worktreeId), - parentWorkspaceKey: folderKey, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 1 - } - state.sparsePresetsByRepo[REPO.id] = [ - { - id: 'preset-1', - repoId: REPO.id, - name: 'UI', - directories: ['src/renderer'], - createdAt: 1, - updatedAt: 1 - } - ] - state.retiredWorktreeNamesByRepo![REPO.id] = { exhaustedTiers: 0, names: ['nautilus'] } - const namespaceKey = getRemoteRetirementNamespaceKey(REPO, state.settings, (targetId) => - state.sshTargets.find((target) => target.id === targetId) - ) - expect(namespaceKey).not.toBeNull() - if (!namespaceKey || !state.retiredWorktreeNamesByNamespace) { - throw new Error('expected source retirement namespace') - } - state.retiredWorktreeNamesByNamespace[namespaceKey] = { - exhaustedTiers: 0, - names: ['seahorse'] - } - state.automations = [automation()] - state.automationRuns = [automationRun()] - state.mobileClientTabSelectionsByDeviceId = { - 'device-1': { - [folderKey]: { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } - } - } - state.ui.lastActiveRepoId = REPO.id - - expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toEqual({ - totalCount: 14, - counts: { - automation: 1, - 'automation-run': 1, - 'mobile-tab-selection': 1, - 'retired-worktree-name': 2, - 'saved-port-forward': 1, - 'sparse-preset': 1, - 'terminal-lease': 1, - 'terminal-recovery': 1, - 'ui-routing': 1, - 'workspace-lineage': 1, - 'workspace-session': 1, - 'worktree-lineage': 1, - 'worktree-metadata': 1 - } - }) - }) - - it('keeps another SSH target and its host partition outside the source fence', () => { - const state = getDefaultPersistedState('/home/test') - state.sshTargets = [TARGET, { ...TARGET, id: 'ssh-other', generation: 9 }] - state.workspaceSessionsByHostId = { - 'ssh:ssh-other': { - ...state.workspaceSession, - tabsByWorktree: { - 'other-repo::/srv/worktree': [ - { - id: 'tab-other', - ptyId: 'pty-other', - worktreeId: 'other-repo::/srv/worktree', - title: 'Other', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - } - - expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST).totalCount).toBe(0) - }) - - it('does not strand a static migration on an owner-recovery tombstone after all leases are final', () => { - const state = getDefaultPersistedState('/home/test') - state.sshTargets = [TARGET] - state.repos = [REPO] - state.folderWorkspaces = [FOLDER] - state.sshRemotePtyLeases = [ - { - targetId: TARGET.id, - ptyId: 'pty-finished', - state: 'terminated', - createdAt: 1, - updatedAt: 2 - } - ] - state.sshPtyConsumerRecoveries = [ - { - targetId: TARGET.id, - clientInstanceId: 'client-1', - serverBuildId: 'build-1', - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'lease-1' - } - ] - - expect(collectOrcadMigrationSourceDependencyCensus(state, MANIFEST)).toMatchObject({ - totalCount: 0, - counts: { 'terminal-recovery': 0 } - }) - }) -}) - -function automation(): Automation { - return { - id: 'automation-1', - name: 'Remote task', - prompt: 'Run checks', - precheck: null, - agentId: 'codex', - projectId: REPO.id, - executionTargetType: 'ssh', - executionTargetId: TARGET.id, - executionTargetGeneration: TARGET.generation, - schedulerOwner: 'ssh_bridge', - workspaceMode: 'existing', - workspaceId: null, - baseBranch: null, - reuseSession: false, - timezone: 'UTC', - rrule: 'FREQ=DAILY', - dtstart: 1, - enabled: true, - nextRunAt: 2, - missedRunPolicy: 'run_once_within_grace', - missedRunGraceMinutes: 60, - createdAt: 1, - updatedAt: 1 - } -} - -function automationRun(): AutomationRun { - return { - id: 'run-1', - automationId: 'automation-1', - title: 'Remote task #1', - scheduledFor: 1, - status: 'completed', - trigger: 'scheduled', - workspaceId: null, - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: null, - terminalPaneKey: null, - terminalPtyId: null, - outputSnapshot: null, - precheckResult: null, - usage: null, - error: null, - startedAt: 1, - dispatchedAt: 1, - createdAt: 1 - } -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts deleted file mode 100644 index b6f317f4440..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { parsePersistedAutomationHostFilter } from '../../../shared/automation-host-filter' -import { getAutomationRunRepoId } from '../../../shared/automation-run-identity' -import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' -import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' -import { inspectOrcadSourceWorktreeMetadata } from './orcad-source-worktree-metadata' -import { - ORCAD_MIGRATION_DEPENDENCY_KINDS, - type OrcadMigrationDependencyKind -} from '../../../shared/orcad-migration-preflight' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' -import { isEmptyRetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' -import { extractRetiredNameRegistriesByNamespace } from '../../orca-profiles/profile-project-retired-name-transfer' -import { - inspectOrcadMigrationSourceSessions, - orcadMigrationPaneBelongsToTabs, - type OrcadMigrationSourceSessionInspection -} from './orcad-source-session-dependencies' -import { - collectOrcadMigrationSourceDormantState, - emptyDormantPayload, - ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS -} from './orcad-source-dormant-state' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export type OrcadMigrationSourceDependencyCensus = { - totalCount: number - counts: Record -} - -export function collectOrcadMigrationSourceDependencyCensus( - state: PersistedState, - manifest: OrcadMigrationManifest, - storage?: TerminalScrollbackSnapshotStorage -): OrcadMigrationSourceDependencyCensus { - return collectDependencyCensus(state, manifest, false, storage) -} - -export function collectOrcadMigrationUntransferredDependencyCensus( - state: PersistedState, - manifest: OrcadMigrationManifest, - storage?: TerminalScrollbackSnapshotStorage -): OrcadMigrationSourceDependencyCensus { - return collectDependencyCensus(state, manifest, true, storage) -} - -function collectDependencyCensus( - state: PersistedState, - manifest: OrcadMigrationManifest, - ignoreTransferredDormantState: boolean, - storage?: TerminalScrollbackSnapshotStorage -): OrcadMigrationSourceDependencyCensus { - const scope = createOrcadMigrationSourceScope({ - source: manifest.source, - catalog: manifest.payload - }) - const counts: Record = { - automation: 0, - 'automation-run': 0, - 'mobile-tab-selection': 0, - 'retired-worktree-name': 0, - 'saved-port-forward': 0, - 'sparse-preset': 0, - 'terminal-lease': 0, - 'terminal-recovery': 0, - 'ui-routing': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - 'worktree-lineage': 0, - 'worktree-metadata': 0 - } - const target = state.sshTargets.find((entry) => entry.id === scope.targetId) - counts['saved-port-forward'] = target?.portForwards?.length ?? 0 - counts['terminal-lease'] = state.sshRemotePtyLeases.filter( - (lease) => lease.targetId === scope.targetId && lease.state !== 'terminated' - ).length - - const sessions = inspectOrcadMigrationSourceSessions(state, { - hostId: scope.hostId, - ownerMatches: (ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope), - targetId: scope.targetId - }) - counts['workspace-session'] = sessions.dependencyCount - counts['terminal-recovery'] = countTerminalRecoveryState(state, scope, sessions) - const metadata = inspectOrcadSourceWorktreeMetadata(state, scope) - counts['worktree-metadata'] = metadata.rows.length + metadata.blockedCount - counts['worktree-lineage'] = Object.entries(state.worktreeLineageById).filter( - ([ownerKey, lineage]) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) || - orcadMigrationOwnerMatchesScope(lineage.worktreeId, scope) || - orcadMigrationOwnerMatchesScope(lineage.parentWorktreeId, scope) - ).length - counts['workspace-lineage'] = Object.entries(state.workspaceLineageByChildKey).filter( - ([ownerKey, lineage]) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) || - orcadMigrationOwnerMatchesScope(lineage.childWorkspaceKey, scope) || - orcadMigrationOwnerMatchesScope(lineage.parentWorkspaceKey, scope) - ).length - counts['sparse-preset'] = [...scope.repoIds].reduce( - (total, repoId) => total + (state.sparsePresetsByRepo[repoId]?.length ?? 0), - 0 - ) - counts['retired-worktree-name'] = countRetiredWorktreeNameState(state, manifest) - - const blockedAutomationIds = new Set( - state.automations - .filter((automation) => automationMatchesScope(automation, scope)) - .map(({ id }) => id) - ) - counts.automation = blockedAutomationIds.size - counts['automation-run'] = state.automationRuns.filter( - (run) => - blockedAutomationIds.has(run.automationId) || - orcadMigrationOwnerMatchesScope(run.workspaceId, scope) || - executionContextMatchesScope(run.runContext, scope) || - executionContextMatchesScope(run.sourceContext, scope) - ).length - counts['mobile-tab-selection'] = Object.values( - state.mobileClientTabSelectionsByDeviceId ?? {} - ).reduce( - (total, selections) => - total + - Object.keys(selections).filter((ownerKey) => orcadMigrationOwnerMatchesScope(ownerKey, scope)) - .length, - 0 - ) - counts['ui-routing'] = countUiRoutingState(state, scope, sessions) - const dormant = collectOrcadMigrationSourceDormantState( - state, - manifest.source, - manifest.payload, - storage, - manifest.destinationEnvironmentId - ) - const dormantMatches = - ignoreTransferredDormantState || - serializeOrcadMigrationValue(dormant.payload) === - serializeOrcadMigrationValue(manifest.payload.dormantState ?? emptyDormantPayload()) - if (dormantMatches) { - for (const kind of ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS) { - counts[kind] = dormant.blockedCounts[kind] - } - } - return { - counts, - totalCount: ORCAD_MIGRATION_DEPENDENCY_KINDS.reduce((total, kind) => total + counts[kind], 0) - } -} - -function countTerminalRecoveryState( - state: PersistedState, - scope: OrcadMigrationSourceScope, - sessions: OrcadMigrationSourceSessionInspection -): number { - // Expired routes may still own remote work; only terminated leases resolve recovery authority. - const hasActiveLease = state.sshRemotePtyLeases.some( - (lease) => lease.targetId === scope.targetId && lease.state !== 'terminated' - ) - let count = (state.sshPtyConsumerRecoveries ?? []).filter( - (recovery) => recovery.targetId === scope.targetId && hasActiveLease - ).length - count += state.migrationUnsupportedPtyEntries.filter( - (entry) => - orcadMigrationOwnerMatchesScope(entry.worktreeId, scope) || - (entry.tabId ? sessions.tabIds.has(entry.tabId) : false) || - sessions.ptyIds.has(entry.ptyId) || - (entry.paneKey ? orcadMigrationPaneBelongsToTabs(entry.paneKey, sessions.tabIds) : false) - ).length - count += state.legacyPaneKeyAliasEntries.filter( - (entry) => - orcadMigrationPaneBelongsToTabs(entry.legacyPaneKey, sessions.tabIds) || - orcadMigrationPaneBelongsToTabs(entry.stablePaneKey, sessions.tabIds) - ).length - return count -} - -function countRetiredWorktreeNameState( - state: PersistedState, - manifest: OrcadMigrationManifest -): number { - let count = manifest.payload.repositories.filter((repo) => { - const registry = state.retiredWorktreeNamesByRepo?.[repo.id] - return registry !== undefined && !isEmptyRetiredNameRegistry(registry) - }).length - const namespaceKeys = new Set() - for (const repo of manifest.payload.repositories) { - Object.keys(extractRetiredNameRegistriesByNamespace(state, repo)).forEach((key) => - namespaceKeys.add(key) - ) - } - count += namespaceKeys.size - return count -} - -function automationMatchesScope( - automation: PersistedState['automations'][number], - scope: OrcadMigrationSourceScope -): boolean { - return ( - (automation.executionTargetType === 'ssh' && automation.executionTargetId === scope.targetId) || - (scope.targetGeneration !== null && - automation.executionTargetGeneration === scope.targetGeneration) || - scope.repoIds.has(getAutomationRunRepoId(automation)) || - orcadMigrationOwnerMatchesScope(automation.workspaceId, scope) || - executionContextMatchesScope(automation.runContext, scope) || - executionContextMatchesScope(automation.sourceContext, scope) - ) -} - -function executionContextMatchesScope( - context: { hostId: string; repoId?: string | null } | null | undefined, - scope: OrcadMigrationSourceScope -): boolean { - return ( - context?.hostId === scope.hostId || - (typeof context?.repoId === 'string' && scope.repoIds.has(context.repoId)) - ) -} - -function countUiRoutingState( - state: PersistedState, - scope: OrcadMigrationSourceScope, - sessions: OrcadMigrationSourceSessionInspection -): number { - const ui = state.ui - let count = ui.lastActiveRepoId && scope.repoIds.has(ui.lastActiveRepoId) ? 1 : 0 - count += orcadMigrationOwnerMatchesScope(ui.lastActiveWorktreeId, scope) ? 1 : 0 - count += ui.filterRepoIds.filter((repoId) => scope.repoIds.has(repoId)).length - count += Object.keys(ui.showDotfilesByWorktree ?? {}).filter((ownerKey) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) - ).length - count += (ui.setupScriptPromptDismissedRepoIds ?? []).filter((repoId) => - scope.repoIds.has(repoId) - ).length - count += (ui.manualRepoOrder ?? []).filter( - (entry) => entry.hostId === scope.hostId || scope.repoIds.has(entry.repoId) - ).length - count += ui.workspaceHostScope === scope.hostId ? 1 : 0 - count += (ui.visibleWorkspaceHostIds ?? []).filter((hostId) => hostId === scope.hostId).length - count += (ui.workspaceHostOrder ?? []).filter((hostId) => hostId === scope.hostId).length - const automationFilter = parsePersistedAutomationHostFilter(ui.automationHostFilter) - count += - automationFilter.kind === 'host' && - automationFilter.host.authority.kind === 'desktop' && - automationFilter.host.selector.kind === 'ssh' && - automationFilter.host.selector.targetId === scope.targetId - ? 1 - : 0 - count += Object.keys(ui.acknowledgedAgentsByPaneKey ?? {}).filter((paneKey) => - orcadMigrationPaneBelongsToTabs(paneKey, sessions.tabIds) - ).length - return count -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts deleted file mode 100644 index 49cd5ce631a..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationDormantStatePayload, - OrcadMigrationManifest, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' -import type { OrcadMigrationDependencyKind } from '../../../shared/orcad-migration-preflight' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' -import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' -import { isEmptyRetiredNameRegistry } from '../../../shared/worktree/retired-name-registry' -import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { toOrcadDestinationRepository } from './orcad-destination-catalog-projection' -import { collectOrcadMigrationRetiredWorktreeNamespaces } from './orcad-source-retired-worktree-names' -import { collectOrcadMigrationSourceAutomationState } from './orcad-source-automation-state' -import { collectOrcadMigrationSourceWorkspaceSession } from './orcad-source-workspace-session' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey -} from './orcad-source-scope' -import { collectOrcadMigrationSourceClientState } from './orcad-source-client-state' -import { inspectOrcadSourceWorktreeMetadata } from './orcad-source-worktree-metadata' - -export const ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS = [ - 'worktree-metadata', - 'worktree-lineage', - 'workspace-lineage', - 'workspace-session', - 'automation', - 'automation-run', - 'sparse-preset', - 'retired-worktree-name', - 'mobile-tab-selection', - 'ui-routing', - 'saved-port-forward' -] as const satisfies readonly OrcadMigrationDependencyKind[] - -type TransferredDormantKind = (typeof ORCAD_MIGRATION_TRANSFERRED_DORMANT_KINDS)[number] - -export type OrcadMigrationSourceDormantInspection = { - payload: OrcadMigrationDormantStatePayload - blockedCounts: Record -} - -export function collectOrcadMigrationSourceDormantState( - state: PersistedState, - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload, - storage?: TerminalScrollbackSnapshotStorage, - destinationEnvironmentId?: string -): OrcadMigrationSourceDormantInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog }) - const blockedCounts = emptyBlockedCounts() - const destinationRepos = catalog.repositories.map(toOrcadDestinationRepository) - const setupByRepoId = new Map( - projectHostSetupProjectionFromRepos(destinationRepos).setups.flatMap((setup) => - setup.repoId ? [[setup.repoId, setup] as const] : [] - ) - ) - const metadata = inspectOrcadSourceWorktreeMetadata(state, scope) - blockedCounts['worktree-metadata'] = metadata.blockedCount - const worktreeMeta = uniqueDestinationRows( - metadata.rows.flatMap(({ sourceKey, meta }) => { - const worktreeId = unqualifyOrcadMigrationOwnerKey(sourceKey) - const setup = setupByRepoId.get(getRepoIdFromWorktreeId(worktreeId)) - return [ - { - sourceKey, - worktreeId, - meta: { - ...structuredClone(meta), - ...(setup ? { projectId: setup.projectId, projectHostSetupId: setup.id } : {}), - hostId: 'local' as const - } - } - ] - }), - (entry) => entry.worktreeId, - () => (blockedCounts['worktree-metadata'] += 1) - ) - const worktreeLineage = uniqueDestinationRows( - Object.entries(state.worktreeLineageById).flatMap(([sourceKey, lineage]) => { - const touches = [sourceKey, lineage.worktreeId, lineage.parentWorktreeId].some((value) => - orcadMigrationOwnerMatchesScope(value, scope) - ) - if (!touches) { - return [] - } - const worktreeId = unqualifyOrcadMigrationOwnerKey(sourceKey) - if ( - worktreeId !== lineage.worktreeId || - !orcadMigrationOwnerMatchesScope(lineage.worktreeId, scope) || - !orcadMigrationOwnerMatchesScope(lineage.parentWorktreeId, scope) - ) { - blockedCounts['worktree-lineage'] += 1 - return [] - } - return [{ sourceKey, worktreeId, lineage: structuredClone(lineage) }] - }), - (entry) => entry.worktreeId, - () => (blockedCounts['worktree-lineage'] += 1) - ) - const workspaceLineage = uniqueDestinationRows( - Object.entries(state.workspaceLineageByChildKey).flatMap(([sourceKey, lineage]) => { - const touches = [sourceKey, lineage.childWorkspaceKey, lineage.parentWorkspaceKey].some( - (value) => orcadMigrationOwnerMatchesScope(value, scope) - ) - if (!touches) { - return [] - } - const childWorkspaceKey = unqualifyOrcadMigrationOwnerKey(sourceKey) - if ( - childWorkspaceKey !== lineage.childWorkspaceKey || - !orcadMigrationOwnerMatchesScope(lineage.childWorkspaceKey, scope) || - !orcadMigrationOwnerMatchesScope(lineage.parentWorkspaceKey, scope) - ) { - blockedCounts['workspace-lineage'] += 1 - return [] - } - return [ - { - sourceKey, - childWorkspaceKey, - lineage: { - ...structuredClone(lineage), - childInstanceId: lineage.childInstanceId ?? null, - parentInstanceId: lineage.parentInstanceId ?? null - } - } - ] - }), - (entry) => entry.childWorkspaceKey, - () => (blockedCounts['workspace-lineage'] += 1) - ) - const sparsePresets = [...scope.repoIds] - .flatMap((repoId) => state.sparsePresetsByRepo[repoId] ?? []) - .map((preset) => structuredClone(preset)) - .sort((left, right) => - compareKeys(`${left.repoId}\0${left.id}`, `${right.repoId}\0${right.id}`) - ) - const retiredWorktreeNames = [...scope.repoIds] - .flatMap((repoId) => { - const registry = state.retiredWorktreeNamesByRepo?.[repoId] - return registry && !isEmptyRetiredNameRegistry(registry) - ? [{ repoId, registry: structuredClone(registry) }] - : [] - }) - .sort((left, right) => compareKeys(left.repoId, right.repoId)) - const workspaceSession = collectOrcadMigrationSourceWorkspaceSession( - state, - source, - catalog, - storage - ) - blockedCounts['workspace-session'] = workspaceSession.blockedCount - const automationState = collectOrcadMigrationSourceAutomationState(state, source, catalog) - blockedCounts.automation = automationState.blockedAutomationCount - blockedCounts['automation-run'] = automationState.blockedRunCount - const clientState = collectOrcadMigrationSourceClientState( - state, - source, - catalog, - destinationEnvironmentId, - workspaceSession.payload - ) - blockedCounts['mobile-tab-selection'] = clientState.blockedCounts['mobile-tab-selection'] - blockedCounts['ui-routing'] = clientState.blockedCounts['ui-routing'] - blockedCounts['saved-port-forward'] = clientState.blockedCounts['saved-port-forward'] - blockedCounts['workspace-session'] += clientState.blockedCount - return { - payload: { - version: 1, - worktreeMeta: worktreeMeta.sort((left, right) => - compareKeys(left.worktreeId, right.worktreeId) - ), - worktreeLineage: worktreeLineage.sort((left, right) => - compareKeys(left.worktreeId, right.worktreeId) - ), - workspaceLineage: workspaceLineage.sort((left, right) => - compareKeys(left.childWorkspaceKey, right.childWorkspaceKey) - ), - sparsePresets, - retiredWorktreeNames, - retiredWorktreeNamespaces: collectOrcadMigrationRetiredWorktreeNamespaces(state, catalog), - ...(workspaceSession.payload ? { workspaceSession: workspaceSession.payload } : {}), - ...(workspaceSession.snapshots.length > 0 - ? { terminalScrollbackSnapshots: workspaceSession.snapshots } - : {}), - ...(automationState.automations.length > 0 - ? { automations: automationState.automations } - : {}), - ...(automationState.automationRuns.length > 0 - ? { automationRuns: automationState.automationRuns } - : {}), - ...(clientState.payload ? { clientState: clientState.payload } : {}) - }, - blockedCounts - } -} - -export function orcadMigrationDormantStateMatchesSource( - state: PersistedState, - manifest: OrcadMigrationManifest, - storage?: TerminalScrollbackSnapshotStorage -): boolean { - const current = collectOrcadMigrationSourceDormantState( - state, - manifest.source, - manifest.payload, - storage, - manifest.destinationEnvironmentId - ).payload - return ( - serializeOrcadMigrationValue(current) === - serializeOrcadMigrationValue(manifest.payload.dormantState ?? emptyDormantPayload()) - ) -} - -export function emptyDormantPayload(): OrcadMigrationDormantStatePayload { - return { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [] - } -} - -function uniqueDestinationRows( - rows: T[], - key: (row: T) => string, - onDuplicate: () => void -): T[] { - const unique = new Map() - const duplicates = new Set() - for (const row of rows) { - const rowKey = key(row) - if (duplicates.has(rowKey)) { - onDuplicate() - } else if (unique.has(rowKey)) { - unique.delete(rowKey) - duplicates.add(rowKey) - onDuplicate() - } else { - unique.set(rowKey, row) - } - } - return [...unique.values()] -} - -function emptyBlockedCounts(): Record { - return { - 'worktree-metadata': 0, - 'worktree-lineage': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - automation: 0, - 'automation-run': 0, - 'sparse-preset': 0, - 'retired-worktree-name': 0, - 'mobile-tab-selection': 0, - 'ui-routing': 0, - 'saved-port-forward': 0 - } -} - -function compareKeys(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts deleted file mode 100644 index b386fec9cc3..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { SshTarget } from '../../../shared/ssh-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' -import { Store } from '../loading-store/store' -import { createOrcadMigrationManifest } from '../../ssh/orcad-migration-manifest-export' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 3 -} -const REPO_ID = 'repo-1' -const WORKTREE_ID = `${REPO_ID}::/srv/app` - -const directories: string[] = [] -afterEach(async () => { - await closeTestStores() - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -function dormantSession(buffer: string): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - [WORKTREE_ID]: [ - { - id: 'tab-1', - ptyId: null, - worktreeId: WORKTREE_ID, - title: 'Shell', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null, - buffersByLeafId: { 'leaf-1': buffer } - } - } - } -} - -function sourceStore(): Store { - const directory = mkdtempSync(join(tmpdir(), 'orcad-source-export-')) - directories.push(directory) - const store = createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) - store.addSshTarget(TARGET) - store.addRepo({ - id: REPO_ID, - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: TARGET.id - }) - store.setWorkspaceSession(dormantSession('dormant output\r\n'), toSshExecutionHostId(TARGET.id)) - return store -} - -describe('exporting a relay-hosted SSH target from the profile store', () => { - it('reads the target catalog and dormant scrollback without changing the source', () => { - const store = sourceStore() - const before = JSON.stringify({ - repos: store.getRepos(), - session: store.getWorkspaceSession(toSshExecutionHostId(TARGET.id)) - }) - - const manifest = createOrcadMigrationManifest(store, TARGET) - - expect(manifest.payload.repositories.map((repo) => repo.id)).toEqual([REPO_ID]) - const snapshots = manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - expect(snapshots).toHaveLength(1) - const chunk = store.readOrcadMigrationSourceSnapshotChunk(manifest, snapshots[0]!.ref, 0) - expect(Buffer.from(chunk.bytesBase64, 'base64').toString('utf8')).toBe('dormant output\r\n') - expect(chunk.eof).toBe(true) - expect( - JSON.stringify({ - repos: store.getRepos(), - session: store.getWorkspaceSession(toSshExecutionHostId(TARGET.id)) - }) - ).toBe(before) - }) - - it('refuses a chunk once the dormant buffer changed after export', () => { - const store = sourceStore() - const manifest = createOrcadMigrationManifest(store, TARGET) - const ref = manifest.payload.dormantState?.terminalScrollbackSnapshots?.[0]?.ref ?? '' - store.setWorkspaceSession(dormantSession('rewritten output'), toSshExecutionHostId(TARGET.id)) - expect(() => store.readOrcadMigrationSourceSnapshotChunk(manifest, ref, 0)).toThrow( - 'orcad_migration_source_snapshot_changed' - ) - }) - - it('refuses a chunk for a manifest whose digest does not match its contents', () => { - const store = sourceStore() - const manifest = createOrcadMigrationManifest(store, TARGET) - const ref = manifest.payload.dormantState?.terminalScrollbackSnapshots?.[0]?.ref ?? '' - expect(() => - store.readOrcadMigrationSourceSnapshotChunk({ ...manifest, migrationId: 'forged' }, ref, 0) - ).toThrow('orcad_migration_manifest_digest_mismatch') - }) - - it('names what still blocks: nothing, once the dormant state is exportable', () => { - const store = sourceStore() - const manifest = createOrcadMigrationManifest(store, TARGET) - const census = store.inspectOrcadMigrationUntransferredDependencies(manifest) - expect(census.counts['workspace-session']).toBe(0) - }) -}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts deleted file mode 100644 index 8e3c118eee8..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * The Store's read-only view of a relay-hosted SSH target, for migrating it to a managed orcad. - * - * Everything here reads the profile-state store and returns copies; nothing writes or retires - * source rows. Retiring the source after a verified import is the cutover's job (T8). - */ -import { - ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, - type OrcadMigrationTerminalScrollbackSnapshot -} from '../../../shared/orcad-migration-scrollback' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationDormantStatePayload, - OrcadMigrationManifest, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import { assertOrcadMigrationManifestDigest } from '../../orcad/orcad-migration-manifest-digest' -import type { StoreRuntimeState } from '../loading-store/store-runtime-state' -import { - collectOrcadMigrationSourceDependencyCensus, - collectOrcadMigrationUntransferredDependencyCensus, - type OrcadMigrationSourceDependencyCensus -} from './orcad-source-dependency-census' -import { collectOrcadMigrationSourceDormantState } from './orcad-source-dormant-state' -import { readOrcadMigrationSourceScrollbackChunk } from './orcad-source-scrollback-state' - -type OrcadSourceExportRuntime = Pick< - StoreRuntimeState, - 'state' | 'terminalScrollbackSnapshotStorage' | 'retainedScrollbackRefsByMigrationId' -> - -const orcadSourceExportContext = Symbol('OrcadSourceExportPersistence') - -export class OrcadSourceExportPersistence { - readonly [orcadSourceExportContext]: OrcadSourceExportRuntime - - constructor(runtime: OrcadSourceExportRuntime) { - this[orcadSourceExportContext] = runtime - } - - collectOrcadMigrationSourceDormantState( - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload, - destinationEnvironmentId?: string - ): OrcadMigrationDormantStatePayload { - const runtime = this[orcadSourceExportContext] - return collectOrcadMigrationSourceDormantState( - runtime.state, - source, - catalog, - runtime.terminalScrollbackSnapshotStorage, - destinationEnvironmentId - ).payload - } - - /** Every dependency on the target, transferable or not; preflight decides what blocks. */ - inspectOrcadMigrationSourceDependencies( - manifest: OrcadMigrationManifest - ): OrcadMigrationSourceDependencyCensus { - const runtime = this[orcadSourceExportContext] - return collectOrcadMigrationSourceDependencyCensus( - runtime.state, - manifest, - runtime.terminalScrollbackSnapshotStorage - ) - } - - /** What still references the target that this manifest cannot carry. */ - inspectOrcadMigrationUntransferredDependencies( - manifest: OrcadMigrationManifest - ): OrcadMigrationSourceDependencyCensus { - const runtime = this[orcadSourceExportContext] - return collectOrcadMigrationUntransferredDependencyCensus( - runtime.state, - manifest, - runtime.terminalScrollbackSnapshotStorage - ) - } - - /** Keeps the snapshot files this manifest names until released, even if their tabs close. */ - retainOrcadMigrationScrollback(manifest: OrcadMigrationManifest): void { - const refs = (manifest.payload.dormantState?.terminalScrollbackSnapshots ?? []).map( - (snapshot) => snapshot.ref - ) - this[orcadSourceExportContext].retainedScrollbackRefsByMigrationId.set( - manifest.migrationId, - new Set(refs) - ) - } - - releaseOrcadMigrationScrollback(migrationId: string): void { - this[orcadSourceExportContext].retainedScrollbackRefsByMigrationId.delete(migrationId) - } - - /** - * One bounded chunk of a scrollback snapshot the signed manifest names. The bytes are checked - * against the manifest's length and digest, so a buffer that changed since export is refused. - */ - readOrcadMigrationSourceSnapshotChunk( - manifest: OrcadMigrationManifest, - ref: string, - offset: number - ): { bytesBase64: string; totalBytes: number; eof: boolean } { - assertOrcadMigrationManifestDigest(manifest) - const descriptor: OrcadMigrationTerminalScrollbackSnapshot | undefined = - manifest.payload.dormantState?.terminalScrollbackSnapshots?.find((entry) => entry.ref === ref) - if (!descriptor) { - throw new Error('orcad_migration_source_snapshot_unknown') - } - const runtime = this[orcadSourceExportContext] - return readOrcadMigrationSourceScrollbackChunk({ - state: runtime.state, - descriptor, - offset, - length: ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, - storage: runtime.terminalScrollbackSnapshotStorage - }) - } -} - -export function installOrcadSourceExportPersistenceContext( - target: OrcadSourceExportPersistence, - source: OrcadSourceExportPersistence -): void { - Object.defineProperty(target, orcadSourceExportContext, { - value: source[orcadSourceExportContext] - }) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts deleted file mode 100644 index 897782737ad..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-retired-worktree-names.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** Retired worktree names (the name registry, not migration retirement) the target carries. */ -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationDormantStatePayload -} from '../../../shared/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import { - isEmptyRetiredNameRegistry, - mergeRetiredNameRegistries, - type RetiredNameRegistry -} from '../../../shared/worktree/retired-name-registry' -import { getRemoteRetirementNamespaceKey } from '../../worktree-name-retirement' -import { - swapRetirementNamespaceHost, - retirementHostIdentity, - retirementNamespaceKeysToRead -} from '../../worktree-retirement-namespace' - -const EMPTY_REGISTRY: RetiredNameRegistry = { exhaustedTiers: 0, names: [] } - -export function collectOrcadMigrationRetiredWorktreeNamespaces( - state: PersistedState, - catalog: OrcadMigrationCatalogPayload -): OrcadMigrationDormantStatePayload['retiredWorktreeNamespaces'] { - const lookup = (targetId: string) => state.sshTargets.find((target) => target.id === targetId) - const byDestination = new Map< - string, - { sourceNamespaceKeys: Set; registry: RetiredNameRegistry } - >() - for (const repo of catalog.repositories) { - const canonicalSource = getRemoteRetirementNamespaceKey(repo, state.settings, lookup) - if (!canonicalSource) { - continue - } - const sourceNamespaceKeys = retirementNamespaceKeysToRead(repo, canonicalSource, lookup).filter( - (key) => state.retiredWorktreeNamesByNamespace?.[key] !== undefined - ) - if (sourceNamespaceKeys.length === 0) { - continue - } - const registry = sourceNamespaceKeys.reduce( - (merged, key) => - mergeRetiredNameRegistries( - merged, - state.retiredWorktreeNamesByNamespace?.[key] ?? { exhaustedTiers: 0, names: [] } - ), - EMPTY_REGISTRY - ) - if (isEmptyRetiredNameRegistry(registry)) { - continue - } - const namespaceKey = swapRetirementNamespaceHost( - canonicalSource, - retirementHostIdentity(repo, lookup), - LOCAL_EXECUTION_HOST_ID - ) - if (!namespaceKey) { - continue - } - const existing = byDestination.get(namespaceKey) - byDestination.set(namespaceKey, { - sourceNamespaceKeys: new Set([ - ...(existing?.sourceNamespaceKeys ?? []), - ...sourceNamespaceKeys - ]), - registry: existing ? mergeRetiredNameRegistries(existing.registry, registry) : registry - }) - } - return [...byDestination.entries()] - .map(([namespaceKey, entry]) => ({ - namespaceKey, - sourceNamespaceKeys: [...entry.sourceNamespaceKeys].sort(compareKeys), - registry: entry.registry - })) - .sort((left, right) => compareKeys(left.namespaceKey, right.namespaceKey)) -} - -function compareKeys(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts deleted file mode 100644 index 26eade4c8b7..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope -} from './orcad-source-scope' - -const scope = createOrcadMigrationSourceScope({ - source: { sshTargetId: 'ssh-prod', sshTargetGeneration: 1, targetLabel: 'Production' }, - catalog: { - repositories: [ - { - id: 'repo-1', - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: 'ssh-prod' - } - ], - projectGroups: [], - folderWorkspaces: [ - { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Notes', - folderPath: '/srv/notes', - connectionId: 'ssh-prod', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } - ] - } -}) - -describe('migration source scope', () => { - it.each([ - ['a worktree of an exported repository', 'repo-1::/srv/app'], - ['an exported folder workspace', 'folder:folder-1'] - ])('owns %s', (_name, ownerKey) => { - expect(orcadMigrationOwnerMatchesScope(ownerKey, scope)).toBe(true) - }) - - it.each([ - ['another repository', 'repo-2::/srv/other'], - ['another folder workspace', 'folder:folder-2'], - ['no owner', null] - ])('does not own %s', (_name, ownerKey) => { - expect(orcadMigrationOwnerMatchesScope(ownerKey, scope)).toBe(false) - }) -}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts deleted file mode 100644 index 32157a6685d..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scope.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { - getWorktreeIdFromHostIdentity, - isWorktreeHostIdentity -} from '../../../shared/worktree/host-qualified-identity' -import { ownerKeyBelongsToRepo } from '../../orca-profiles/profile-project-worktree-identity' - -export type OrcadMigrationSourceScope = { - targetId: string - targetGeneration: number | null - hostId: ReturnType - repoIds: ReadonlySet - folderWorkspaceKeys: ReadonlySet -} - -export function createOrcadMigrationSourceScope(args: { - source: OrcadMigrationManifestSource - catalog: OrcadMigrationCatalogPayload -}): OrcadMigrationSourceScope { - return { - targetId: args.source.sshTargetId, - targetGeneration: args.source.sshTargetGeneration, - hostId: toSshExecutionHostId(args.source.sshTargetId), - repoIds: new Set(args.catalog.repositories.map((repo) => repo.id)), - folderWorkspaceKeys: new Set( - args.catalog.folderWorkspaces.map((workspace) => `folder:${workspace.id}`) - ) - } -} - -export function orcadMigrationOwnerMatchesScope( - value: string | null | undefined, - scope: OrcadMigrationSourceScope -): boolean { - if (!value) { - return false - } - const rawValue = isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value - if (scope.folderWorkspaceKeys.has(rawValue)) { - return true - } - for (const repoId of scope.repoIds) { - if (ownerKeyBelongsToRepo(rawValue, repoId)) { - return true - } - } - const parsed = parseWorkspaceKey(rawValue) - return ( - parsed?.type === 'folder' && scope.folderWorkspaceKeys.has(`folder:${parsed.folderWorkspaceId}`) - ) -} - -export function unqualifyOrcadMigrationOwnerKey(value: string): string { - return isWorktreeHostIdentity(value) ? getWorktreeIdFromHostIdentity(value) : value -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts deleted file mode 100644 index 516365f8fa3..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' -import { hasDuplicateOrcadMigrationScrollbackDescriptors } from './orcad-source-scrollback-state' - -const FIRST: OrcadMigrationTerminalScrollbackSnapshot = { - tabId: 'tab-1', - leafId: 'leaf-1', - ref: `v1-${'1'.repeat(32)}`, - sha256: 'a'.repeat(64), - byteLength: 1 -} - -describe('orcad source scrollback projection', () => { - it('refuses duplicate refs or tab/leaf identities across merged fragments', () => { - expect( - hasDuplicateOrcadMigrationScrollbackDescriptors([ - FIRST, - { ...FIRST, tabId: 'tab-2', leafId: 'leaf-2' } - ]) - ).toBe(true) - expect( - hasDuplicateOrcadMigrationScrollbackDescriptors([ - FIRST, - { ...FIRST, ref: `v1-${'2'.repeat(32)}` } - ]) - ).toBe(true) - expect( - hasDuplicateOrcadMigrationScrollbackDescriptors([ - FIRST, - { - ...FIRST, - tabId: 'tab-2', - leafId: 'leaf-2', - ref: `v1-${'2'.repeat(32)}` - } - ]) - ).toBe(false) - }) -}) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts deleted file mode 100644 index 668ef4ac59d..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createHash } from 'node:crypto' -import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' -import { - MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS, - MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES -} from '../../../shared/orcad-migration-scrollback' -import type { PersistedState } from '../../../shared/persisted-state-types' -import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { - makeTerminalScrollbackSnapshotRef, - readTerminalScrollbackStoredBytesSync, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' - -export type ProjectedOrcadMigrationScrollback = { - session: WorkspaceSessionState - snapshots: OrcadMigrationTerminalScrollbackSnapshot[] - blockedCount: number -} - -export function hasDuplicateOrcadMigrationScrollbackDescriptors( - snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] -): boolean { - const refs = new Set() - const leaves = new Set() - for (const snapshot of snapshots) { - const leaf = `${snapshot.tabId}\0${snapshot.leafId}` - if (refs.has(snapshot.ref) || leaves.has(leaf)) { - return true - } - refs.add(snapshot.ref) - leaves.add(leaf) - } - return false -} - -export function projectOrcadMigrationSessionScrollback( - session: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage -): ProjectedOrcadMigrationScrollback { - const projected = structuredClone(session) - const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] - let blockedCount = 0 - let totalBytes = 0 - for (const [tabId, layout] of Object.entries(projected.terminalLayoutsByTabId)) { - const sourceLayout = session.terminalLayoutsByTabId[tabId] - const refs: Record = {} - const leafIds = new Set([ - ...Object.keys(sourceLayout.buffersByLeafId ?? {}), - ...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {}) - ]) - for (const leafId of [...leafIds].sort(compareKeys)) { - const buffer = sourceLayout.buffersByLeafId?.[leafId] - const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId] - const ref = buffer ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef - const bytes = buffer - ? Buffer.from(buffer, 'utf8') - : sourceRef - ? readTerminalScrollbackStoredBytesSync(sourceRef, storage) - : null - if ( - !ref || - !bytes || - bytes.length === 0 || - bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - ) { - blockedCount += 1 - continue - } - totalBytes += bytes.length - refs[leafId] = ref - snapshots.push({ - tabId, - leafId, - ref, - sha256: createHash('sha256').update(bytes).digest('hex'), - byteLength: bytes.length - }) - } - delete layout.buffersByLeafId - if (Object.keys(refs).length > 0) { - layout.scrollbackRefsByLeafId = refs - } else { - delete layout.scrollbackRefsByLeafId - } - } - if ( - snapshots.length > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS || - totalBytes > MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES - ) { - blockedCount += 1 - } - snapshots.sort((left, right) => - compareKeys(`${left.tabId}\0${left.leafId}`, `${right.tabId}\0${right.leafId}`) - ) - return { session: projected, snapshots, blockedCount } -} - -export function readOrcadMigrationSourceScrollbackChunk(args: { - state: PersistedState - descriptor: OrcadMigrationTerminalScrollbackSnapshot - offset: number - length: number - storage?: TerminalScrollbackSnapshotStorage -}): { bytesBase64: string; totalBytes: number; eof: boolean } { - const bytes = findSnapshotBytes(args.state, args.descriptor, args.storage) - if (!bytes) { - throw new Error('orcad_migration_source_snapshot_changed') - } - if (!Number.isSafeInteger(args.offset) || args.offset < 0 || args.offset > bytes.length) { - throw new Error('orcad_migration_source_snapshot_offset_invalid') - } - const end = Math.min(bytes.length, args.offset + args.length) - return { - bytesBase64: bytes.subarray(args.offset, end).toString('base64'), - totalBytes: bytes.length, - eof: end === bytes.length - } -} - -function findSnapshotBytes( - state: PersistedState, - descriptor: OrcadMigrationTerminalScrollbackSnapshot, - storage?: TerminalScrollbackSnapshotStorage -): Buffer | null { - for (const session of sessionPartitions(state)) { - const layout = session.terminalLayoutsByTabId[descriptor.tabId] - if (!layout) { - continue - } - const buffer = layout.buffersByLeafId?.[descriptor.leafId] - const ref = layout.scrollbackRefsByLeafId?.[descriptor.leafId] - const bytes = buffer - ? Buffer.from(buffer, 'utf8') - : ref === descriptor.ref - ? readTerminalScrollbackStoredBytesSync(ref, storage) - : null - if ( - bytes && - bytes.length === descriptor.byteLength && - createHash('sha256').update(bytes).digest('hex') === descriptor.sha256 - ) { - return bytes - } - } - return null -} - -function sessionPartitions(state: PersistedState): WorkspaceSessionState[] { - return [state.workspaceSession, ...Object.values(state.workspaceSessionsByHostId ?? {})].filter( - (session): session is WorkspaceSessionState => session !== undefined - ) -} - -function compareKeys(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts deleted file mode 100644 index c6f51cb82ed..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-session-dependencies.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { ExecutionHostId } from '../../../shared/execution-host' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' - -export type OrcadMigrationSourceSessionInspection = { - dependencyCount: number - ptyIds: Set - tabIds: Set -} - -type SessionScope = { - targetId: string - hostId: ExecutionHostId - ownerMatches: (ownerKey: string) => boolean -} - -export function inspectOrcadMigrationSourceSessions( - state: PersistedState, - scope: SessionScope -): OrcadMigrationSourceSessionInspection { - const aggregate: OrcadMigrationSourceSessionInspection = { - dependencyCount: 0, - ptyIds: new Set(), - tabIds: new Set() - } - const partitions: [string, WorkspaceSessionState][] = [ - ['local', state.workspaceSession], - ...Object.entries(state.workspaceSessionsByHostId ?? {}).flatMap( - ([hostId, session]): [string, WorkspaceSessionState][] => (session ? [[hostId, session]] : []) - ) - ] - for (const [hostId, session] of partitions) { - const inspected = inspectSession(session, scope, hostId === scope.hostId) - aggregate.dependencyCount += inspected.dependencyCount - inspected.ptyIds.forEach((value) => aggregate.ptyIds.add(value)) - inspected.tabIds.forEach((value) => aggregate.tabIds.add(value)) - } - return aggregate -} - -function inspectSession( - session: WorkspaceSessionState, - scope: SessionScope, - sourceHostPartition: boolean -): OrcadMigrationSourceSessionInspection { - const result: OrcadMigrationSourceSessionInspection = { - dependencyCount: 0, - ptyIds: new Set(), - tabIds: new Set() - } - const matchesOwner = (ownerKey: string): boolean => - Boolean(ownerKey) && (sourceHostPartition || scope.ownerMatches(ownerKey)) - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!matchesOwner(ownerKey)) { - continue - } - result.dependencyCount += 1 - for (const tab of tabs) { - result.tabIds.add(tab.id) - if (tab.ptyId) { - result.ptyIds.add(tab.ptyId) - } - } - } - const browserWorkspaceIds = new Set() - for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { - if (!matchesOwner(ownerKey)) { - continue - } - result.dependencyCount += 1 - workspaces.forEach((workspace) => browserWorkspaceIds.add(workspace.id)) - } - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - if (!matchesOwner(ownerKey)) { - continue - } - for (const tab of tabs) { - if (tab.contentType === 'terminal') { - result.tabIds.add(tab.entityId) - } else if (tab.contentType === 'browser') { - browserWorkspaceIds.add(tab.entityId) - } - } - } - result.dependencyCount += countOwnedRecordKeys(session, matchesOwner) - result.dependencyCount += Object.keys(session.browserPagesByWorkspace ?? {}).filter( - (workspaceId) => browserWorkspaceIds.has(workspaceId) - ).length - result.dependencyCount += Object.keys(session.terminalLayoutsByTabId).filter((tabId) => - result.tabIds.has(tabId) - ).length - result.dependencyCount += Object.keys(session.remoteSessionIdsByTabId ?? {}).filter((tabId) => - result.tabIds.has(tabId) - ).length - for (const paneKey of Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {})) { - if (orcadMigrationPaneBelongsToTabs(paneKey, result.tabIds)) { - result.dependencyCount += 1 - } - } - for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { - if (matchesOwner(tombstone.worktreeId)) { - result.dependencyCount += 1 - result.ptyIds.add(tombstone.ptyId) - } - } - for (const sleeping of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - if (matchesOwner(sleeping.worktreeId) || sleeping.connectionId === scope.targetId) { - result.dependencyCount += 1 - } - } - result.dependencyCount += (session.activeWorktreeIdsOnShutdown ?? []).filter(matchesOwner).length - result.dependencyCount += session.activeRepoId && scope.ownerMatches(session.activeRepoId) ? 1 : 0 - result.dependencyCount += matchesOwner(session.activeWorktreeId ?? '') ? 1 : 0 - result.dependencyCount += matchesOwner(session.activeWorkspaceKey ?? '') ? 1 : 0 - result.dependencyCount += session.activeWorkspaceExecutionHostId === scope.hostId ? 1 : 0 - result.dependencyCount += (session.activeConnectionIdsAtShutdown ?? []).filter( - (targetId) => targetId === scope.targetId - ).length - result.dependencyCount += session.activeTabId && result.tabIds.has(session.activeTabId) ? 1 : 0 - return result -} - -function countOwnedRecordKeys( - session: WorkspaceSessionState, - matchesOwner: (ownerKey: string) => boolean -): number { - let count = 0 - for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) { - count += Object.keys(session[field] ?? {}).filter(matchesOwner).length - } - return count -} - -export function orcadMigrationPaneBelongsToTabs( - paneKey: string, - tabIds: ReadonlySet -): boolean { - for (const tabId of tabIds) { - if (paneKey.startsWith(`${tabId}:`)) { - return true - } - } - return false -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts deleted file mode 100644 index 2a43579a9b8..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-eligibility.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { isWorkspaceKey } from '../../../shared/workspace-scope' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { buildMarkdownFrontmatterIdMap } from '../../orca-profiles/profile-session-owner-transfer' -import { - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey, - type OrcadMigrationSourceScope -} from './orcad-source-scope' -import { - paneBelongsToTabs, - paneBelongsToTerminalLayout -} from './orcad-source-workspace-session-layout' -import { collectSessionOwnerKeys } from './orcad-source-workspace-session-fragments' - -export function countUnrepresentableMarkdownState( - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - sourceHostPartition: boolean -): number { - const projection = { - mapOwnerKey: (ownerKey: string) => - sourceHostPartition || orcadMigrationOwnerMatchesScope(ownerKey, scope) - ? unqualifyOrcadMigrationOwnerKey(ownerKey) - : null, - mapWorktreeId: unqualifyOrcadMigrationOwnerKey - } - const mappings = buildMarkdownFrontmatterIdMap(session.openFilesByWorktree, projection) - return Object.keys(session.markdownFrontmatterVisible ?? {}).filter( - (fileId) => mappings.get(fileId) === null - ).length -} - -export function countUnsupportedSessionState( - state: PersistedState, - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - sourceHostPartition: boolean, - terminalTabIds: ReadonlySet -): number { - const owns = (ownerKey: string): boolean => orcadMigrationOwnerMatchesScope(ownerKey, scope) - const matches = (ownerKey: string): boolean => - Boolean(ownerKey) && (sourceHostPartition || owns(ownerKey)) - let count = sourceHostPartition - ? [...collectSessionOwnerKeys(session)].filter((ownerKey) => !owns(ownerKey)).length - : 0 - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!owns(ownerKey)) { - continue - } - tabs.forEach((tab) => { - count += tab.ptyId ? 1 : 0 - }) - } - for (const tabId of terminalTabIds) { - const layout = session.terminalLayoutsByTabId[tabId] - count += Object.keys(layout?.ptyIdsByLeafId ?? {}).length - count += session.remoteSessionIdsByTabId?.[tabId] ? 1 : 0 - } - count += Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).filter((paneKey) => - paneBelongsToTabs(paneKey, terminalTabIds) - ).length - count += Object.values(session.sleepingAgentSessionsByPaneKey ?? {}).filter((record) => { - const touchesSource = matches(record.worktreeId) || record.connectionId === scope.targetId - return ( - touchesSource && !transferableSleepingAgentSession(record, session, scope, terminalTabIds) - ) - }).length - count += Object.entries(session.clientHostedBrowserPagesByWorktree ?? {}) - .filter(([ownerKey]) => matches(ownerKey)) - .filter( - ([ownerKey, pages]) => !clientHostedPagesAreTransferable(session, ownerKey, pages) - ).length - count += (session.activeWorktreeIdsOnShutdown ?? []).filter( - (worktreeId) => - matches(worktreeId) && - shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) - ).length - count += - session.activeRepoId && - owns(session.activeRepoId) && - !(sourceHostPartition && scope.repoIds.has(session.activeRepoId)) - ? 1 - : 0 - count += - session.activeWorktreeId && - matches(session.activeWorktreeId) && - !(sourceHostPartition && orcadMigrationOwnerMatchesScope(session.activeWorktreeId, scope)) - ? 1 - : 0 - count += - session.activeWorkspaceKey && - matches(session.activeWorkspaceKey) && - !(sourceHostPartition && orcadMigrationOwnerMatchesScope(session.activeWorkspaceKey, scope)) - ? 1 - : 0 - count += session.activeWorkspaceExecutionHostId === scope.hostId && !sourceHostPartition ? 1 : 0 - count += (session.activeConnectionIdsAtShutdown ?? []).filter( - (targetId) => targetId === scope.targetId - ).length - count += - session.activeTabId && terminalTabIds.has(session.activeTabId) && !sourceHostPartition ? 1 : 0 - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - if (owns(ownerKey)) { - count += files.filter( - (file) => file.externalSshTargetId !== undefined || Boolean(file.runtimeEnvironmentId) - ).length - } - } - for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { - if (owns(ownerKey)) { - count += workspaces.filter((workspace) => - Boolean(workspace.sessionProfileId || workspace.sessionPartition) - ).length - } - } - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - if (owns(ownerKey)) { - count += tabs.filter( - (tab) => tab.executionHostId !== undefined && tab.executionHostId !== scope.hostId - ).length - } - } - return count -} - -export function shutdownMarkerHasTerminalAuthority( - state: PersistedState, - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - sourceHostPartition: boolean, - worktreeId: string -): boolean { - const marker = unqualifyOrcadMigrationOwnerKey(worktreeId) - const ownerMatchesMarker = (ownerKey: string): boolean => - (sourceHostPartition || orcadMigrationOwnerMatchesScope(ownerKey, scope)) && - unqualifyOrcadMigrationOwnerKey(ownerKey) === marker - const tabIds = new Set() - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - if (!ownerMatchesMarker(ownerKey)) { - continue - } - for (const tab of tabs) { - tabIds.add(tab.id) - if (tab.ptyId) { - return true - } - const layout = session.terminalLayoutsByTabId[tab.id] - if (Object.keys(layout?.ptyIdsByLeafId ?? {}).length > 0) { - return true - } - if (session.remoteSessionIdsByTabId?.[tab.id]) { - return true - } - } - } - if ( - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).some((paneKey) => - paneBelongsToTabs(paneKey, tabIds) - ) - ) { - return true - } - return state.sshRemotePtyLeases.some( - (lease) => - lease.targetId === scope.targetId && - lease.state !== 'terminated' && - (lease.worktreeId === undefined || - unqualifyOrcadMigrationOwnerKey(lease.worktreeId) === marker) - ) -} - -function clientHostedPagesAreTransferable( - session: WorkspaceSessionState, - ownerKey: string, - pages: NonNullable[string] -): boolean { - const browserWorkspaceIds = new Set( - (session.browserTabsByWorktree?.[ownerKey] ?? []).map((workspace) => workspace.id) - ) - return pages.every((page) => browserWorkspaceIds.has(page.workspaceId)) -} - -export function projectDormantSessionFocus( - source: WorkspaceSessionState, - transferred: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - terminalTabIds: ReadonlySet -): void { - // These scalars are UI focus, not execution ownership. They are safe to carry - // only from the source host partition and only when they point at an entity - // already proven dormant and included in the projected session. - if (source.activeRepoId && scope.repoIds.has(source.activeRepoId)) { - transferred.activeRepoId = source.activeRepoId - } - if (source.activeWorktreeId && orcadMigrationOwnerMatchesScope(source.activeWorktreeId, scope)) { - transferred.activeWorktreeId = unqualifyOrcadMigrationOwnerKey(source.activeWorktreeId) - } - const activeWorkspaceKey = - source.activeWorkspaceKey && orcadMigrationOwnerMatchesScope(source.activeWorkspaceKey, scope) - ? unqualifyOrcadMigrationOwnerKey(source.activeWorkspaceKey) - : null - if (activeWorkspaceKey && isWorkspaceKey(activeWorkspaceKey)) { - transferred.activeWorkspaceKey = activeWorkspaceKey - } - if (source.activeWorkspaceExecutionHostId === scope.hostId) { - transferred.activeWorkspaceExecutionHostId = LOCAL_EXECUTION_HOST_ID - } - if (source.activeTabId && terminalTabIds.has(source.activeTabId)) { - transferred.activeTabId = source.activeTabId - } -} - -export function projectSessionToDestination( - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope -): WorkspaceSessionState { - const projected = structuredClone(session) - for (const tabs of Object.values(projected.unifiedTabs ?? {})) { - for (const tab of tabs) { - if (tab.executionHostId === scope.hostId) { - tab.executionHostId = LOCAL_EXECUTION_HOST_ID - } - } - } - return projected -} - -export function transferableSleepingAgentSession( - record: SleepingAgentSessionRecord, - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope, - terminalTabIds: ReadonlySet -): boolean { - return ( - orcadMigrationOwnerMatchesScope(record.worktreeId, scope) && - (record.connectionId == null || record.connectionId === scope.targetId) && - // Older profiles can still contain a worker-resume fence; never discard its authority. - (!('automaticResumeBlockedBy' in record) || record.automaticResumeBlockedBy === undefined) && - ((record.origin ?? 'worktree-sleep') === 'worktree-sleep' || - paneBelongsToTerminalLayout(record, session, terminalTabIds)) - ) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts deleted file mode 100644 index 3f3bf33da9e..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-fragments.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { mergeWorkspaceSessions } from '../../orca-profiles/profile-project-session-state' -import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' - -export function sessionPartitions( - state: { - workspaceSession: WorkspaceSessionState - workspaceSessionsByHostId?: Record - }, - localHostId: string -): [string, WorkspaceSessionState][] { - return [ - [localHostId, state.workspaceSession], - ...Object.entries(state.workspaceSessionsByHostId ?? {}).flatMap( - ([hostId, session]): [string, WorkspaceSessionState][] => (session ? [[hostId, session]] : []) - ) - ] -} - -export function mergeSessionFragments( - fragments: WorkspaceSessionState[] -): WorkspaceSessionState | null { - const ownerKeys = new Set() - const entityKeys = new Set() - let merged: WorkspaceSessionState | undefined - for (const fragment of fragments) { - if ( - hasDuplicates(ownerKeys, collectSessionOwnerKeys(fragment)) || - hasDuplicates(entityKeys, collectSessionEntityKeys(fragment)) - ) { - return null - } - merged = mergeWorkspaceSessions(merged, fragment) - } - return merged ?? null -} - -export function collectSessionOwnerKeys(session: WorkspaceSessionState): Set { - const keys = new Set() - const fields = [ - 'tabsByWorktree', - 'openFilesByWorktree', - 'browserTabsByWorktree', - 'unifiedTabs', - 'tabGroups', - ...SESSION_FIELDS_PRUNED_BY_OWNER_KEY - ] as const - for (const field of fields) { - Object.keys(session[field] ?? {}).forEach((key) => keys.add(key)) - } - Object.values(session.tabsByWorktree) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.openFilesByWorktree ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.browserTabsByWorktree ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.browserPagesByWorkspace ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.unifiedTabs ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.tabGroups ?? {}) - .flat() - .forEach((entry) => keys.add(entry.worktreeId)) - Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {}).forEach((entry) => - keys.add(entry.worktreeId) - ) - Object.values(session.sleepingAgentSessionsByPaneKey ?? {}).forEach((entry) => - keys.add(entry.worktreeId) - ) - return keys -} - -export function collectSessionEntityKeys(session: WorkspaceSessionState): string[] { - const keys: string[] = [] - Object.values(session.tabsByWorktree) - .flat() - .forEach((tab) => keys.push(`terminal:${tab.id}`)) - Object.values(session.browserTabsByWorktree ?? {}) - .flat() - .forEach((tab) => keys.push(`browser:${tab.id}`)) - Object.values(session.clientHostedBrowserPagesByWorktree ?? {}) - .flat() - .forEach((page) => keys.push(`client-browser-page:${page.browserPageId}`)) - Object.values(session.unifiedTabs ?? {}) - .flat() - .forEach((tab) => keys.push(`tab:${tab.id}`)) - Object.values(session.tabGroups ?? {}) - .flat() - .forEach((group) => keys.push(`group:${group.id}`)) - Object.keys(session.terminalSurfaceTombstonesByPaneKey ?? {}).forEach((key) => - keys.push(`tombstone:${key}`) - ) - Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).forEach((key) => - keys.push(`sleeping-agent:${key}`) - ) - return keys -} - -function hasDuplicates(seen: Set, incoming: Iterable): boolean { - let duplicate = false - for (const key of incoming) { - if (seen.has(key)) { - duplicate = true - } - seen.add(key) - } - return duplicate -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts deleted file mode 100644 index c541bad0cba..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session-layout.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { - orcadMigrationOwnerMatchesScope, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export function collectOwnedTerminalTabIds( - session: WorkspaceSessionState, - scope: OrcadMigrationSourceScope -): Set { - const tabIds = new Set( - Object.entries(session.tabsByWorktree).flatMap(([ownerKey, tabs]) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) ? tabs.map((tab) => tab.id) : [] - ) - ) - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - if (!orcadMigrationOwnerMatchesScope(ownerKey, scope)) { - continue - } - for (const tab of tabs) { - if (tab.contentType === 'terminal') { - tabIds.add(tab.id) - tabIds.add(tab.entityId) - } - } - } - return tabIds -} - -export function paneBelongsToTerminalLayout( - record: SleepingAgentSessionRecord, - session: WorkspaceSessionState, - terminalTabIds: ReadonlySet -): boolean { - const separator = record.paneKey.lastIndexOf(':') - if (separator < 1) { - return false - } - const tabId = record.paneKey.slice(0, separator) - const leafId = record.paneKey.slice(separator + 1) - if ((record.tabId !== undefined && record.tabId !== tabId) || !terminalTabIds.has(tabId)) { - return false - } - return terminalLayoutContainsLeaf(session.terminalLayoutsByTabId[tabId]?.root, leafId) -} - -function terminalLayoutContainsLeaf( - node: WorkspaceSessionState['terminalLayoutsByTabId'][string]['root'] | undefined, - leafId: string -): boolean { - return Boolean( - node && - (node.type === 'leaf' - ? node.leafId === leafId - : terminalLayoutContainsLeaf(node.first, leafId) || - terminalLayoutContainsLeaf(node.second, leafId)) - ) -} - -export function paneBelongsToTabs(paneKey: string, tabIds: ReadonlySet): boolean { - const separator = paneKey.lastIndexOf(':') - return separator > 0 && tabIds.has(paneKey.slice(0, separator)) -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts deleted file mode 100644 index e56a1943a57..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationManifestSource -} from '../../../shared/orcad-migration-manifest' -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration-scrollback' -import { - extractSessionOwnersForTransfer, - hasTransferredSessionState -} from '../../orca-profiles/profile-session-owner-transfer' -import type { TerminalScrollbackSnapshotStorage } from '../../terminal-scrollback-snapshots' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey -} from './orcad-source-scope' -import { - countUnrepresentableMarkdownState, - countUnsupportedSessionState, - projectDormantSessionFocus, - projectSessionToDestination, - shutdownMarkerHasTerminalAuthority, - transferableSleepingAgentSession -} from './orcad-source-workspace-session-eligibility' -import { collectOwnedTerminalTabIds } from './orcad-source-workspace-session-layout' -import { - mergeSessionFragments, - sessionPartitions -} from './orcad-source-workspace-session-fragments' -import { - hasDuplicateOrcadMigrationScrollbackDescriptors, - projectOrcadMigrationSessionScrollback -} from './orcad-source-scrollback-state' - -export type OrcadMigrationSourceWorkspaceSessionInspection = { - payload: WorkspaceSessionState | undefined - snapshots: OrcadMigrationTerminalScrollbackSnapshot[] - blockedCount: number -} - -export function collectOrcadMigrationSourceWorkspaceSession( - state: PersistedState, - source: OrcadMigrationManifestSource, - catalog: OrcadMigrationCatalogPayload, - storage?: TerminalScrollbackSnapshotStorage -): OrcadMigrationSourceWorkspaceSessionInspection { - const scope = createOrcadMigrationSourceScope({ source, catalog }) - const fragments: WorkspaceSessionState[] = [] - const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] - let blockedCount = 0 - for (const [partitionId, session] of sessionPartitions(state, LOCAL_EXECUTION_HOST_ID)) { - const sourceHostPartition = partitionId === scope.hostId - const terminalTabIds = collectOwnedTerminalTabIds(session, scope) - blockedCount += countUnsupportedSessionState( - state, - session, - scope, - sourceHostPartition, - terminalTabIds - ) - blockedCount += countUnrepresentableMarkdownState(session, scope, sourceHostPartition) - const fragment = extractSessionOwnersForTransfer(session, { - mapOwnerKey: (ownerKey) => - orcadMigrationOwnerMatchesScope(ownerKey, scope) - ? unqualifyOrcadMigrationOwnerKey(ownerKey) - : null, - mapWorktreeId: unqualifyOrcadMigrationOwnerKey, - projectSessionFocus: sourceHostPartition - ? ({ source, transferred, terminalTabIds }) => - projectDormantSessionFocus(source, transferred, scope, terminalTabIds) - : undefined, - projectSleepingAgentSession: (record) => - transferableSleepingAgentSession(record, session, scope, terminalTabIds) - ? { - ...structuredClone(record), - worktreeId: unqualifyOrcadMigrationOwnerKey(record.worktreeId), - connectionId: null - } - : null - }) - // A shutdown marker is only a reconnect hint. Once the source has no live - // PTY authority for that worktree, carrying it would make the destination - // try to resurrect a process that no longer exists. - if (fragment.activeWorktreeIdsOnShutdown) { - fragment.activeWorktreeIdsOnShutdown = fragment.activeWorktreeIdsOnShutdown.filter( - (worktreeId) => - shutdownMarkerHasTerminalAuthority(state, session, scope, sourceHostPartition, worktreeId) - ) - if (fragment.activeWorktreeIdsOnShutdown.length === 0) { - delete fragment.activeWorktreeIdsOnShutdown - } - } - if (hasTransferredSessionState(fragment)) { - const projected = projectOrcadMigrationSessionScrollback( - projectSessionToDestination(fragment, scope), - storage - ) - blockedCount += projected.blockedCount - snapshots.push(...projected.snapshots) - fragments.push(projected.session) - } - } - if (hasDuplicateOrcadMigrationScrollbackDescriptors(snapshots)) { - blockedCount += 1 - } - if (blockedCount > 0 || fragments.length === 0) { - return { payload: undefined, snapshots: [], blockedCount } - } - const merged = mergeSessionFragments(fragments) - return merged - ? { payload: merged, snapshots, blockedCount: 0 } - : { payload: undefined, snapshots: [], blockedCount: 1 } -} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts deleted file mode 100644 index 930bf5d1886..00000000000 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-worktree-metadata.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import type { OrcadMigrationManifest } from '../../../shared/orcad-migration-manifest' -import { serializeOrcadMigrationValue } from '../../../shared/orcad-migration-manifest' -import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity' -import { - getExecutionHostIdFromWorktreeHostIdentity, - isWorktreeHostIdentity -} from '../../../shared/worktree/host-qualified-identity' -import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree-identity-metadata' -import { - createOrcadMigrationSourceScope, - orcadMigrationOwnerMatchesScope, - unqualifyOrcadMigrationOwnerKey, - type OrcadMigrationSourceScope -} from './orcad-source-scope' - -export function inspectOrcadSourceWorktreeMetadata( - state: PersistedState, - scope: OrcadMigrationSourceScope -) { - const rows: { sourceKey: string; meta: WorktreeMeta }[] = [] - let blockedCount = 0 - for (const [sourceKey, meta] of Object.entries(state.worktreeMeta)) { - const host = getExecutionHostIdFromWorktreeHostIdentity(sourceKey) - if ((host && host !== scope.hostId) || (meta.hostId && meta.hostId !== scope.hostId)) { - if (host === scope.hostId || meta.hostId === scope.hostId) { - blockedCount++ - } - continue - } - if (orcadMigrationOwnerMatchesScope(sourceKey, scope)) { - rows.push({ sourceKey, meta }) - } else if (meta.hostId === scope.hostId || host === scope.hostId) { - blockedCount++ - } - } - const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat()) - for (const [identity, meta] of Object.entries(state.worktreeMetaByIdentity ?? {})) { - if (meta.hostId === scope.hostId && !referenced.has(identity)) { - blockedCount++ - } - } - for (const [alias, identities] of Object.entries(state.worktreeIdentityAliases ?? {})) { - if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== scope.hostId) { - continue - } - const meta = identities.length === 1 ? state.worktreeMetaByIdentity?.[identities[0]] : undefined - const worktreeId = unqualifyOrcadMigrationOwnerKey(alias) - if ( - isWorktreeHostIdentity(worktreeId) || - !orcadMigrationOwnerMatchesScope(alias, scope) || - !meta || - !meta.instanceId || - (meta.hostId !== undefined && meta.hostId !== scope.hostId) || - identities[0] !== - canonicalWorktreeIdentity({ - worktreeId, - executionHostId: scope.hostId, - instanceId: meta.instanceId - }) - ) { - blockedCount++ - continue - } - const legacy = rows.filter( - (row) => unqualifyOrcadMigrationOwnerKey(row.sourceKey) === worktreeId - ) - if ( - legacy.length > 1 || - (legacy.length === 1 && - serializeOrcadMigrationValue({ ...legacy[0].meta, hostId: scope.hostId }) !== - serializeOrcadMigrationValue({ ...meta, hostId: scope.hostId })) - ) { - // Neither representation may silently discard data held only by its competing row. - blockedCount++ - continue - } - if (legacy.length === 0) { - rows.push({ sourceKey: alias, meta: { ...meta, hostId: scope.hostId } }) - } - } - return { rows, blockedCount } -} - -export function retireOrcadSourceWorktreeMetadata( - state: PersistedState, - manifest: OrcadMigrationManifest -) { - const scope = createOrcadMigrationSourceScope({ - source: manifest.source, - catalog: manifest.payload - }) - const entries = manifest.payload.dormantState?.worktreeMeta ?? [] - const worktreeIds = new Set(entries.map((entry) => entry.worktreeId)) - const removedIdentities = new Set() - for (const [alias, identities] of Object.entries(state.worktreeIdentityAliases ?? {})) { - if ( - getExecutionHostIdFromWorktreeHostIdentity(alias) === scope.hostId && - worktreeIds.has(unqualifyOrcadMigrationOwnerKey(alias)) - ) { - identities.forEach((identity) => removedIdentities.add(identity)) - delete state.worktreeIdentityAliases?.[alias] - } - } - entries.forEach((entry) => delete state.worktreeMeta[entry.sourceKey]) - pruneUnreferencedWorktreeIdentityMeta(state, removedIdentities) -} - -export function assertOrcadSourceWorktreeMetadataRetired( - state: PersistedState, - manifest: OrcadMigrationManifest -) { - const scope = createOrcadMigrationSourceScope({ - source: manifest.source, - catalog: manifest.payload - }) - const inspection = inspectOrcadSourceWorktreeMetadata(state, scope) - if (inspection.rows.length || inspection.blockedCount) { - throw new Error('orcad_migration_source_worktree_metadata_reappeared') - } -} diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts index b7171f75dc0..04ef209bef1 100644 --- a/src/main/providers/ssh-filesystem-dispatch.ts +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -37,16 +37,6 @@ export function unregisterSshFilesystemProvider(connectionId: string): void { sshProviders.delete(connectionId) } -export function unregisterSshFilesystemProviderIfCurrent( - connectionId: string, - expected: IFilesystemProvider -): boolean { - if (sshProviders.get(connectionId) !== expected) { - return false - } - return sshProviders.delete(connectionId) -} - export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined { return sshProviders.get(connectionId) } diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts index 73f120bcb13..2a73d57f9c7 100644 --- a/src/main/providers/ssh-git-dispatch.ts +++ b/src/main/providers/ssh-git-dispatch.ts @@ -49,21 +49,6 @@ export function _getSshGitProviderGenerationCacheSize(): number { return sshProviderGenerations.size } -export function unregisterSshGitProviderIfCurrent( - connectionId: string, - expected: SshGitProvider, - expectedGeneration: number -): boolean { - if ( - sshProviders.get(connectionId) !== expected || - getSshGitProviderGeneration(connectionId) !== expectedGeneration - ) { - return false - } - unregisterSshGitProvider(connectionId) - return true -} - export function getSshGitProvider(connectionId: string): SshGitProvider | undefined { return sshProviders.get(connectionId) } diff --git a/src/main/providers/ssh-pty-notification-recovery-activation.test.ts b/src/main/providers/ssh-pty-notification-recovery-activation.test.ts deleted file mode 100644 index e2198f77027..00000000000 --- a/src/main/providers/ssh-pty-notification-recovery-activation.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createSubscription, sourceActivation } from './ssh-pty-notification-routing-test-fixture' - -describe('subscribeSshPtyNotifications', () => { - it('accepts non-empty recovery from the activation checkpoint', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - expect(onData).not.toHaveBeenCalled() - lease.commit() - expect(onData).toHaveBeenCalledWith( - expect.objectContaining({ - data: 'next', - source: expect.objectContaining({ sourceStartSu: 4, sourceEndSu: 8 }) - }) - ) - }) - - it('routes held and later recovery frames only to the private sink until commit', () => { - const { handler, dataListeners, livePtyIds, installReceivingActivation } = createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 12 }) - ) - const publishSource = (data: string, sourceEndSu: number): void => { - handler('pty.data', { - id: 'pty-1', - data, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu, - sourceLengthSu: 4 - }) - } - - publishSource('held', 8) - const recoveryLease = lease.transferToRecovery(onRecoveryData) - publishSource('next', 12) - - expect(onRecoveryData.mock.calls.map(([payload]) => payload.data)).toEqual(['held', 'next']) - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - - recoveryLease.commit() - expect(onData).not.toHaveBeenCalled() - publishSource('live', 16) - - expect(onRecoveryData).toHaveBeenCalledTimes(2) - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'live' })) - expect(livePtyIds).toContain('ssh:conn@@pty-1') - }) - - it('retires an exited private recovery when its activation commits', () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 4 })) - handler('pty.data', { - id: 'pty-1', - data: 'held', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 4, - sourceLengthSu: 4 - }) - const recoveryLease = lease.transferToRecovery(onRecoveryData) - - handler('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) - recoveryLease.commit() - handler('pty.data', { - id: 'pty-1', - data: 'late', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - expect(onRecoveryData).toHaveBeenCalledOnce() - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ id: 'pty-1', deliveryToken: 'token-1' }) - ) - }) - - it('retires private recovery locally and restores the exact predecessor', () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - installReceivingActivation( - 'pty-1', - sourceActivation({ deliveryToken: 'token-old', recoveryEndSu: 3 }) - ).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 6 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - replacement.transferToRecovery(onRecoveryData).retire() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onRecoveryData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - expect(mux.request).not.toHaveBeenCalled() - }) - - it('rejects a stale activation without disturbing current continuity', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() - - expect(() => - installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 1, - ownerGeneration: 4, - deliveryToken: 'token-stale', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - ).toThrow('ssh_source_receiving_activation_stale') - - handler('pty.data', { - id: 'pty-1', - data: 'one', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - expect(onData).toHaveBeenCalledOnce() - }) - - it('drops provisional frames and settles cancellation before rollback completes', async () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - await expect(lease.rollback()).resolves.toBe(true) - - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - }) - - it('restores the exact prior cursor when a replacement rolls back after frames', async () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - await replacement.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - }) - - it('does not let an older lease rollback replace a newer activation', async () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const older = installReceivingActivation('pty-1', sourceActivation()) - const newer = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new' - }) - ) - - await older.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - newer.commit() - - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - expect(mux.request).not.toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ deliveryToken: 'token-new' }) - ) - }) -}) diff --git a/src/main/providers/ssh-pty-notification-routing-recovery.test.ts b/src/main/providers/ssh-pty-notification-routing-recovery.test.ts deleted file mode 100644 index 2ad66f62b18..00000000000 --- a/src/main/providers/ssh-pty-notification-routing-recovery.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' -import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' - -type MockMux = { - onNotification: ReturnType - request: ReturnType -} - -function createSubscription() { - const mux: MockMux = { - onNotification: vi.fn(), - request: vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) - } - const dataListeners = new Set<(payload: { id: string; data: string }) => void>() - const replayListeners = new Set<(payload: { id: string; data: string }) => void>() - const exitListeners = new Set<(payload: { id: string; code: number }) => void>() - const livePtyIds = new Set() - const recordExit = vi.fn() - const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) - const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) - - const subscription = subscribeSshPtyNotifications({ - mux: mux as never, - toAppPtyId, - dataListeners: dataListeners as never, - replayListeners: replayListeners as never, - exitListeners: exitListeners as never, - livePtyIds, - recordExit, - providerGeneration: 7, - resolvePtyIncarnation, - peekPtyIncarnation: () => undefined - }) - const handler = mux.onNotification.mock.calls[0]?.[0] as ( - method: string, - params: Record - ) => void - if (!handler) { - throw new Error('notification handler was not registered') - } - return { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation, - installReceivingActivation: subscription.installReceivingActivation - } -} - -function sourceActivation( - overrides: Partial = {} -): PtySourceReceivingActivation { - return Object.freeze({ - status: 'pending', - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - checkpointSourceEndSu: 0, - recoveryEndSu: 0, - ...overrides - }) -} - -describe('SSH PTY notification recovery routing', () => { - it('rejects a stale activation without disturbing current continuity', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() - - expect(() => - installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 1, - ownerGeneration: 4, - deliveryToken: 'token-stale', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - ).toThrow('ssh_source_receiving_activation_stale') - - handler('pty.data', { - id: 'pty-1', - data: 'one', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - expect(onData).toHaveBeenCalledOnce() - }) - - it('drops provisional frames and settles cancellation before rollback completes', async () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - await expect(lease.rollback()).resolves.toBe(true) - - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - }) - - it('restores the exact prior cursor when a replacement rolls back after frames', async () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - await replacement.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - }) - - it('does not let an older lease rollback replace a newer activation', async () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const older = installReceivingActivation('pty-1', sourceActivation()) - const newer = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new' - }) - ) - - await older.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - newer.commit() - - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - expect(mux.request).not.toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ deliveryToken: 'token-new' }) - ) - }) - - it('ignores PTY methods with missing ids', () => { - const { handler, toAppPtyId, dataListeners } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - - expect(() => handler('pty.data', { data: 'orphan' })).not.toThrow() - expect(toAppPtyId).not.toHaveBeenCalled() - expect(onData).not.toHaveBeenCalled() - }) - - it('leaves recovery and cancellation control methods to their dedicated handlers', () => { - const { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation - } = createSubscription() - const onData = vi.fn() - const onReplay = vi.fn() - const onExit = vi.fn() - dataListeners.add(onData) - replayListeners.add(onReplay) - exitListeners.add(onExit) - livePtyIds.add('ssh:conn@@unrelated') - - for (const method of [ - 'pty.recoveryData', - 'pty.recoveryComplete', - 'pty.restoreRequired', - 'pty.deliveryCanceled' - ]) { - handler(method, { - id: 'pty-1', - data: 'control', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3 - }) - } - - expect(toAppPtyId).not.toHaveBeenCalled() - expect(resolvePtyIncarnation).not.toHaveBeenCalled() - expect(recordExit).not.toHaveBeenCalled() - expect(onData).not.toHaveBeenCalled() - expect(onReplay).not.toHaveBeenCalled() - expect(onExit).not.toHaveBeenCalled() - expect(livePtyIds).toEqual(new Set(['ssh:conn@@unrelated'])) - expect(mux.request).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/ssh-pty-notification-routing-test-fixture.ts b/src/main/providers/ssh-pty-notification-routing-test-fixture.ts deleted file mode 100644 index 6d86d673037..00000000000 --- a/src/main/providers/ssh-pty-notification-routing-test-fixture.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { vi, type Mock } from 'vitest' -import { - subscribeSshPtyNotifications, - type SshPtyNotificationSubscription -} from './ssh-pty-notification-routing' -import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' - -type NotificationHandler = (method: string, params: Record) => void -type CancellationRequest = ( - method: string, - params: Record -) => Promise<{ canceled: boolean; sentEndSu: number; creditedEndSu: number }> - -type MockMux = { - onNotification: Mock<(handler: NotificationHandler) => void> - request: Mock -} - -export type SshPtyNotificationTestSubscription = { - handler: NotificationHandler - mux: MockMux - toAppPtyId: Mock<(id: string) => string> - dataListeners: Set<(payload: { id: string; data: string }) => void> - replayListeners: Set<(payload: { id: string; data: string }) => void> - exitListeners: Set<(payload: { id: string; code: number }) => void> - livePtyIds: Set - recordExit: Mock<(relayPtyId: string, incarnationId: unknown) => void> - resolvePtyIncarnation: Mock<(id: string) => string> - installReceivingActivation: SshPtyNotificationSubscription['installReceivingActivation'] -} - -export function createSubscription(): SshPtyNotificationTestSubscription { - const mux: MockMux = { - onNotification: vi.fn<(handler: NotificationHandler) => void>(), - request: vi.fn(async () => ({ - canceled: true, - sentEndSu: 0, - creditedEndSu: 0 - })) - } - const dataListeners = new Set<(payload: { id: string; data: string }) => void>() - const replayListeners = new Set<(payload: { id: string; data: string }) => void>() - const exitListeners = new Set<(payload: { id: string; code: number }) => void>() - const livePtyIds = new Set() - const recordExit = vi.fn<(relayPtyId: string, incarnationId: unknown) => void>() - const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) - const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) - - const subscription = subscribeSshPtyNotifications({ - mux: mux as never, - toAppPtyId, - dataListeners: dataListeners as never, - replayListeners: replayListeners as never, - exitListeners: exitListeners as never, - livePtyIds, - recordExit, - providerGeneration: 7, - resolvePtyIncarnation, - peekPtyIncarnation: () => undefined - }) - - const handler = mux.onNotification.mock.calls[0]?.[0] - if (!handler) { - throw new Error('notification handler was not registered') - } - - return { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation, - installReceivingActivation: subscription.installReceivingActivation - } -} - -export function sourceActivation( - overrides: Partial = {} -): PtySourceReceivingActivation { - return Object.freeze({ - status: 'pending', - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - checkpointSourceEndSu: 0, - recoveryEndSu: 0, - ...overrides - }) -} diff --git a/src/main/providers/ssh-pty-notification-routing.test.ts b/src/main/providers/ssh-pty-notification-routing.test.ts index 332e8c3cdd6..fdb6776bf36 100644 --- a/src/main/providers/ssh-pty-notification-routing.test.ts +++ b/src/main/providers/ssh-pty-notification-routing.test.ts @@ -1,5 +1,74 @@ import { describe, expect, it, vi } from 'vitest' -import { createSubscription, sourceActivation } from './ssh-pty-notification-routing-test-fixture' +import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' +import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' + +type MockMux = { + onNotification: ReturnType + request: ReturnType +} + +function createSubscription() { + const mux: MockMux = { + onNotification: vi.fn(), + request: vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) + } + const dataListeners = new Set<(payload: { id: string; data: string }) => void>() + const replayListeners = new Set<(payload: { id: string; data: string }) => void>() + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const livePtyIds = new Set() + const recordExit = vi.fn() + const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) + const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) + + const subscription = subscribeSshPtyNotifications({ + mux: mux as never, + toAppPtyId, + dataListeners: dataListeners as never, + replayListeners: replayListeners as never, + exitListeners: exitListeners as never, + livePtyIds, + recordExit, + providerGeneration: 7, + resolvePtyIncarnation, + peekPtyIncarnation: () => undefined + }) + + const handler = mux.onNotification.mock.calls[0]?.[0] as ( + method: string, + params: Record + ) => void + if (!handler) { + throw new Error('notification handler was not registered') + } + + return { + handler, + mux, + toAppPtyId, + dataListeners, + replayListeners, + exitListeners, + livePtyIds, + recordExit, + resolvePtyIncarnation, + installReceivingActivation: subscription.installReceivingActivation + } +} + +function sourceActivation( + overrides: Partial = {} +): PtySourceReceivingActivation { + return Object.freeze({ + status: 'pending', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + checkpointSourceEndSu: 0, + recoveryEndSu: 0, + ...overrides + }) +} describe('subscribeSshPtyNotifications', () => { it('ignores non-PTY notifications without mapping params.id', () => { @@ -411,6 +480,328 @@ describe('subscribeSshPtyNotifications', () => { expect(mux.request).not.toHaveBeenCalled() }) + it('accepts non-empty recovery from the activation checkpoint', () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) + ) + + handler('pty.data', { + id: 'pty-1', + data: 'next', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + expect(onData).not.toHaveBeenCalled() + lease.commit() + expect(onData).toHaveBeenCalledWith( + expect.objectContaining({ + data: 'next', + source: expect.objectContaining({ sourceStartSu: 4, sourceEndSu: 8 }) + }) + ) + }) + + it('routes held and later recovery frames only to the private sink until commit', () => { + const { handler, dataListeners, livePtyIds, installReceivingActivation } = createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 12 }) + ) + const publishSource = (data: string, sourceEndSu: number): void => { + handler('pty.data', { + id: 'pty-1', + data, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu, + sourceLengthSu: 4 + }) + } + + publishSource('held', 8) + const recoveryLease = lease.transferToRecovery(onRecoveryData) + publishSource('next', 12) + + expect(onRecoveryData.mock.calls.map(([payload]) => payload.data)).toEqual(['held', 'next']) + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + + recoveryLease.commit() + expect(onData).not.toHaveBeenCalled() + publishSource('live', 16) + + expect(onRecoveryData).toHaveBeenCalledTimes(2) + expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'live' })) + expect(livePtyIds).toContain('ssh:conn@@pty-1') + }) + + it('retires an exited private recovery when its activation commits', () => { + const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = + createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 4 })) + handler('pty.data', { + id: 'pty-1', + data: 'held', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 4, + sourceLengthSu: 4 + }) + const recoveryLease = lease.transferToRecovery(onRecoveryData) + + handler('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + recoveryLease.commit() + handler('pty.data', { + id: 'pty-1', + data: 'late', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + expect(onRecoveryData).toHaveBeenCalledOnce() + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + expect(mux.request).toHaveBeenCalledWith( + 'pty.cancelDelivery', + expect.objectContaining({ id: 'pty-1', deliveryToken: 'token-1' }) + ) + }) + + it('retires private recovery locally and restores the exact predecessor', () => { + const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + installReceivingActivation( + 'pty-1', + sourceActivation({ deliveryToken: 'token-old', recoveryEndSu: 3 }) + ).commit() + handler('pty.data', { + id: 'pty-1', + data: 'pre', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + const replacement = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new', + checkpointSourceEndSu: 3, + recoveryEndSu: 6 + }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + replacement.transferToRecovery(onRecoveryData).retire() + handler('pty.data', { + id: 'pty-1', + data: 'old', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + expect(onRecoveryData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) + expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) + expect(mux.request).not.toHaveBeenCalled() + }) + + it('rejects a stale activation without disturbing current continuity', () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() + + expect(() => + installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 1, + ownerGeneration: 4, + deliveryToken: 'token-stale', + checkpointSourceEndSu: 3, + recoveryEndSu: 3 + }) + ) + ).toThrow('ssh_source_receiving_activation_stale') + + handler('pty.data', { + id: 'pty-1', + data: 'one', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + expect(onData).toHaveBeenCalledOnce() + }) + + it('drops provisional frames and settles cancellation before rollback completes', async () => { + const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = + createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'next', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + await expect(lease.rollback()).resolves.toBe(true) + + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-1' + }) + }) + + it('restores the exact prior cursor when a replacement rolls back after frames', async () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() + handler('pty.data', { + id: 'pty-1', + data: 'pre', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + const replacement = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new', + checkpointSourceEndSu: 3, + recoveryEndSu: 3 + }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + await replacement.rollback() + handler('pty.data', { + id: 'pty-1', + data: 'old', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) + }) + + it('does not let an older lease rollback replace a newer activation', async () => { + const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const older = installReceivingActivation('pty-1', sourceActivation()) + const newer = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new' + }) + ) + + await older.rollback() + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + newer.commit() + + expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) + expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-1' + }) + expect(mux.request).not.toHaveBeenCalledWith( + 'pty.cancelDelivery', + expect.objectContaining({ deliveryToken: 'token-new' }) + ) + }) + it('ignores PTY methods with missing ids', () => { const { handler, toAppPtyId, dataListeners } = createSubscription() const onData = vi.fn() diff --git a/src/main/providers/ssh-pty-process-list.ts b/src/main/providers/ssh-pty-process-list.ts deleted file mode 100644 index 5c9c2fa8248..00000000000 --- a/src/main/providers/ssh-pty-process-list.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtyProcessInfo } from './types' -import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' -import { mapSshPtyProcessList } from './ssh-agent-session-process-list' -import type { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' - -export function createSshPtyProcessLister( - args: Pick< - Parameters[0], - 'mux' | 'connectionId' | 'livePtyIds' | 'outputState' - > -): IPtyProvider['listProcesses'] { - return (options) => - listSshPtyProcesses({ - ...args, - includeForegroundProcessEvidence: options?.includeForegroundProcessEvidence, - deadlineMs: options?.deadlineMs - }) -} - -export async function listSshPtyProcesses( - args: Readonly<{ - mux: SshChannelMultiplexer - connectionId: string - livePtyIds: Set - outputState: SshPtyProviderOutputState - includeForegroundProcessEvidence?: boolean - deadlineMs?: number - }> -): Promise { - const result = await args.mux.request( - 'pty.listProcesses', - args.includeForegroundProcessEvidence === undefined - ? undefined - : { includeForegroundProcessEvidence: args.includeForegroundProcessEvidence }, - args.deadlineMs === undefined - ? undefined - : { timeoutMs: Math.max(1, args.deadlineMs - Date.now()) } - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the relay answers pty.listProcesses with PtyProcessInfo rows; the mapper rejects unproven ownership. - const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => - toAppSshPtyId(args.connectionId, id) - ) - for (const process of processes) { - args.livePtyIds.add(process.id) - args.outputState.rememberPtyIncarnation( - toRelaySshPtyId(args.connectionId, process.id), - process.incarnationId - ) - } - return processes -} diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index dea156218a1..59088247e18 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' @@ -13,6 +13,7 @@ import type { } from './ssh-pty-provider-contract' import { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' import { spawnFreshSshPty } from './ssh-agent-session-create-operation' +import { mapSshPtyProcessList } from './ssh-agent-session-process-list' import { requestSshPtyAttach, reattachSshPtySessionForSpawn, @@ -25,7 +26,6 @@ import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-pty-spawn-repair' import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations' -import { createSshPtyProcessLister } from './ssh-pty-process-list' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -38,7 +38,6 @@ export class SshPtyProvider implements IPtyProvider { private connectionId: string private livePtyIds = new Set() readonly getAppliedSize: NonNullable - readonly listProcesses: IPtyProvider['listProcesses'] private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() private readonly outputState: SshPtyProviderOutputState @@ -102,12 +101,6 @@ export class SshPtyProvider implements IPtyProvider { this.spawnExitRaces.recordExit(relayPtyId, incarnationId) } }) - this.listProcesses = createSshPtyProcessLister({ - mux, - connectionId, - livePtyIds: this.livePtyIds, - outputState: this.outputState - }) } dispose(): void { @@ -277,6 +270,26 @@ export class SshPtyProvider implements IPtyProvider { this.livePtyIds.delete(id) } + async listProcesses(opts?: { + deadlineMs?: number + includeForegroundProcessEvidence?: boolean + }): Promise { + const result = await this.mux.request( + 'pty.listProcesses', + opts?.includeForegroundProcessEvidence === undefined + ? undefined + : { includeForegroundProcessEvidence: opts.includeForegroundProcessEvidence }, + relayTimeoutOptions(opts?.deadlineMs) + ) + const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) + for (const process of processes) { + this.livePtyIds.add(process.id) + const relayPtyId = this.toRelayPtyId(process.id) + this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId) + } + return processes + } + hasPty = (id: string): boolean => this.livePtyIds.has(id) onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback) diff --git a/src/main/providers/ssh-pty-spawn-env.test.ts b/src/main/providers/ssh-pty-spawn-env.test.ts deleted file mode 100644 index 9c43d7a502b..00000000000 --- a/src/main/providers/ssh-pty-spawn-env.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' - -describe('buildSshPtySpawnEnv relay bridge', () => { - it('prepends the CLI bin dir once and publishes the Node relay bridge', () => { - const env = buildSshPtySpawnEnv({ - env: { PATH: '/home/me/.orca-relay/bin:/usr/bin' }, - remoteCliBridgeEnv: { - binDir: '/home/me/.orca-relay/bin', - relayDir: '/home/me/.orca-relay/relay-v1', - nodePath: '/usr/bin/node', - sockPath: '/home/me/.orca-relay/relay.sock' - } - }) - - expect(env).toMatchObject({ - PATH: '/home/me/.orca-relay/bin:/usr/bin', - ORCA_REMOTE_CLI_BIN_DIR: '/home/me/.orca-relay/bin', - ORCA_RELAY_DIR: '/home/me/.orca-relay/relay-v1', - ORCA_RELAY_NODE_PATH: '/usr/bin/node', - ORCA_RELAY_SOCKET_PATH: '/home/me/.orca-relay/relay.sock' - }) - expect(env).not.toHaveProperty('ORCA_RELAY_CREDENTIAL_FILE') - }) -}) diff --git a/src/main/runtime/orca-runtime-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index 32d47c59951..5064944215b 100644 --- a/src/main/runtime/orca-runtime-automation-operations.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -198,10 +198,6 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg return this.legacyWorkerRecovery.reconcile(options) } - stopLegacyWorkerTerminalRecovery(): Promise { - return this.legacyWorkerRecovery.stop() - } - protected updateLegacyWorkerTerminalRecoveryRetry( plan: LegacyWorkerTerminalRecoveryPlan, deferredDispatchIds: ReadonlySet, diff --git a/src/main/runtime/orca-runtime-headless-terminal-close.test.ts b/src/main/runtime/orca-runtime-headless-terminal-close.test.ts deleted file mode 100644 index 425a84c7bf9..00000000000 --- a/src/main/runtime/orca-runtime-headless-terminal-close.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - PTY_ID, - TAB_ID, - WORKTREE_ID, - createHarness, - type CloseContinuityHarness -} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' - -async function closeByPty(harness: CloseContinuityHarness): Promise { - const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( - (candidate) => candidate.ptyId === PTY_ID - ) - if (!terminal) { - throw new Error('fixture pane has no terminal handle') - } - return harness.runtime.closeTerminal(terminal.handle) -} - -/** A PTY-backed tab with no paired-viewer surface: the path orcad serves to the CLI. */ -function headlessPtyTab(): CloseContinuityHarness { - const harness = createHarness({ registerPtyBacked: true }) - harness.syncFixtureTabWithoutLeaf() - harness.setVerifiedStopResult(true) - return harness -} - -describe('closing a terminal by PTY on a host without a renderer tab', () => { - it('stops the PTY without asking a missing renderer to close the tab', async () => { - const harness = headlessPtyTab() - harness.syncEmptyGraph() - - await expect(closeByPty(harness)).resolves.toMatchObject({ tabId: TAB_ID }) - - expect(harness.closeTerminalTab).not.toHaveBeenCalled() - expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, expect.anything()) - }) - - it('does not fail the close when the advisory renderer notification throws', async () => { - const harness = headlessPtyTab() - harness.syncEmptyGraph() - harness.closeTerminal.mockImplementation(() => { - throw new Error('renderer gone') - }) - - await expect(closeByPty(harness)).resolves.toMatchObject({ tabId: TAB_ID }) - }) -}) diff --git a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts index ea954fbce8b..a243d5ea0a6 100644 --- a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts +++ b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts @@ -130,7 +130,7 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier.closeTerminal?.(tabId) } const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) @@ -147,35 +147,17 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF throw error } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - // Why the tabs guard: a headless host has no renderer tab to close through the notifier. - if ( - closesTab && - !surface && - pty.pty.tabId && - this.tabs.has(tabId) && - this.notifier?.closeTerminalTab - ) { + if (closesTab && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) { const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) - let tabClosed = true - try { - await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) - } catch (error) { - // The tab went away concurrently; fall through and close the PTY alone. - if (!(error instanceof Error) || error.message !== 'tab_not_found') { - throw error - } - tabClosed = false - } - if (tabClosed) { - const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) - } + await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) + const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) if (!closesTab) { @@ -194,10 +176,10 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) } } else { - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) } return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 762d61cf3aa..cbf6e0d8e0c 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,5 +1,4 @@ import { STATUS_METHODS } from './status' -import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad-terminal-census' import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' @@ -55,7 +54,6 @@ import { AGENT_LAUNCH_METHODS } from './agent-launch' // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS = [ ...STATUS_METHODS, - ...ORCAD_TERMINAL_CENSUS_METHODS, ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, ...ARTIFACT_METHODS, diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.ts deleted file mode 100644 index a35156c721b..00000000000 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineMethod } from '../core' -import { - ORCAD_TERMINAL_CENSUS_METHOD, - OrcadTerminalCensusParamsSchema -} from '../../../../shared/orcad-terminal-census' - -export const ORCAD_TERMINAL_CENSUS_METHODS = [ - defineMethod({ - name: ORCAD_TERMINAL_CENSUS_METHOD, - params: OrcadTerminalCensusParamsSchema, - handler: async (params) => { - // Why lazy: the census reaches the daemon modules, whose xterm polyfill defines a global - // `window`; importing them statically would load it into every process with the dispatcher. - const { collectOrcadTerminalCensus } = await import('../../../orcad/orcad-terminal-census') - return collectOrcadTerminalCensus(params.activatedAt) - } - }) -] diff --git a/src/main/runtime/rpc/node-websocket-lifecycle.ts b/src/main/runtime/rpc/node-websocket-lifecycle.ts deleted file mode 100644 index e37e38375da..00000000000 --- a/src/main/runtime/rpc/node-websocket-lifecycle.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { Server as HttpServer } from 'node:http' -import type { Server as HttpsServer } from 'node:https' -import type { WebSocket, WebSocketServer } from 'ws' -import type { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' - -type WebSocketMessagePayload = string | Uint8Array -export type WebSocketMessageHandler = { - bivarianceHack( - msg: WebSocketMessagePayload, - reply: (response: string) => void, - ws: WebSocket - ): void -}['bivarianceHack'] - -export type WebSocketConnectionCloseHandler = ( - clientId: string | null, - ws: WebSocket, - hasOtherConnections: boolean -) => void - -// Why: WS connections are long-lived and multiplex many RPCs by `id`; auth and dispatch are delegated to the message handler. -export function attachNodeWebSocketLifecycle(args: { - ws: WebSocket - heartbeat: RemoteRuntimeServerHeartbeat - preAuthTimeoutMs: number - preAuthTimers: WeakMap> - clientIds: Map - heartbeatConnections: Set - // Why: read lazily so a handler registered after start still reaches sockets accepted earlier. - getMessageHandler: () => WebSocketMessageHandler | null - getConnectionCloseHandler: () => WebSocketConnectionCloseHandler | null -}): void { - const { ws } = args - let finalized = false - const onPong = (): void => args.heartbeat.noteAlive(ws) - const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { - // Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request. - args.heartbeat.noteAlive(ws) - const message = - typeof data === 'string' ? data : isBinary ? toBinaryPayload(data) : data.toString() - args.getMessageHandler()?.( - message, - (response) => { - // Why: mobile clients disconnect often; guard the write so we don't throw on a dead socket. - if (ws.readyState === ws.OPEN) { - ws.send(response) - } - }, - ws - ) - } - const finalize = (): void => { - if (finalized) { - return - } - finalized = true - ws.off('pong', onPong) - ws.off('message', onMessage) - ws.off('close', finalize) - ws.off('error', onError) - clearNodeWebSocketPreAuthTimer(ws, args.preAuthTimers) - args.heartbeatConnections.delete(ws) - if (args.heartbeatConnections.size === 0) { - args.heartbeat.stop() - } - const clientId = args.clientIds.get(ws) ?? null - args.clientIds.delete(ws) - const hasOtherConnections = - clientId !== null && Array.from(args.clientIds.values()).includes(clientId) - args.getConnectionCloseHandler()?.(clientId, ws, hasOtherConnections) - } - const onError = (): void => { - // Why: close isn't guaranteed after every error path; finalize here too so pre-auth E2EE state and connection ids can't leak. - finalize() - ws.close() - } - const preAuthTimer = setTimeout(() => { - if (!args.clientIds.has(ws)) { - // Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake. - ws.terminate() - } - }, args.preAuthTimeoutMs) - preAuthTimer.unref?.() - args.preAuthTimers.set(ws, preAuthTimer) - ws.on('pong', onPong) - ws.on('message', onMessage) - // Why: clean up connection-scoped state (e.g. mobile-fit overrides) so a dropped phone doesn't leave orphaned phone-fit on desktop. - ws.on('close', finalize) - ws.on('error', onError) - // Why: install lifecycle ownership before periodic heartbeat ticks can observe this socket. - args.heartbeatConnections.add(ws) - args.heartbeat.noteAlive(ws) - if (args.heartbeatConnections.size === 1) { - // Unauthenticated sockets are protected by the pre-auth timeout; heartbeat probes begin only - // after E2EE binds a client id, avoiding control frames during the handshake. - args.heartbeat.start(() => args.clientIds.keys()) - } -} - -function toBinaryPayload(data: Exclude): Uint8Array { - return Array.isArray(data) ? Buffer.concat(data) : new Uint8Array(data) -} - -export function clearNodeWebSocketPreAuthTimer( - ws: WebSocket, - preAuthTimers: WeakMap> -): void { - const timer = preAuthTimers.get(ws) - if (timer) { - clearTimeout(timer) - preAuthTimers.delete(ws) - } -} - -export async function stopNodeWebSocketTransport(args: { - wss: WebSocketServer | null - httpServer: HttpServer | HttpsServer | null - heartbeat: RemoteRuntimeServerHeartbeat - heartbeatConnections: Set -}): Promise { - args.heartbeat.stop() - args.heartbeatConnections.clear() - if (args.wss) { - for (const client of args.wss.clients) { - // Why: a half-open mobile socket may never answer a close frame, which keeps httpServer.close pending. - client.terminate() - } - args.wss.close() - } - const httpServer = args.httpServer - if (httpServer) { - await new Promise((resolve, reject) => { - httpServer.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - // Why: idle keep-alive static-web connections would otherwise hold close() open. - httpServer.closeAllConnections() - }) - } -} - -// Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing. -export function rejectNodeWebSocketOverCapacity(ws: WebSocket): void { - ws.on('error', () => {}) - ws.close(1013, 'Maximum connections reached') - const terminateTimer = setTimeout(() => ws.terminate(), 1_000) - terminateTimer.unref?.() - ws.once('close', () => clearTimeout(terminateTimer)) -} diff --git a/src/main/runtime/rpc/websocket-transport-limits.ts b/src/main/runtime/rpc/websocket-transport-limits.ts deleted file mode 100644 index e2e0b3d16ce..00000000000 --- a/src/main/runtime/rpc/websocket-transport-limits.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024 -// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs. -export const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128 -// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound. -export const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2 diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 90be4229d42..45798a89528 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -1,12 +1,10 @@ import { EventEmitter } from 'node:events' import { mkdtempSync } from 'node:fs' -import { connect } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, afterEach, vi } from 'vitest' import WebSocket from 'ws' import { WebSocketTransport } from './ws-transport' -import { rejectNodeWebSocketOverCapacity } from './node-websocket-lifecycle' import { loadOrCreateTlsCertificate } from '../tls-certificate' // Why: disable TLS verification for self-signed certs in tests. @@ -435,7 +433,9 @@ describe('WebSocketTransport', () => { vi.useFakeTimers() try { - rejectNodeWebSocketOverCapacity(serverSocket!) + ;(transport as unknown as { rejectOverCapacity(ws: WebSocket): void }).rejectOverCapacity( + serverSocket! + ) vi.advanceTimersByTime(1_000) } finally { vi.useRealTimers() @@ -455,24 +455,6 @@ describe('WebSocketTransport', () => { await transport.stop() }) - it('stops while an HTTP client holds an unanswered request open', async () => { - const transport = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) - transports.push(transport) - await transport.start() - const socket = connect(transport.resolvedPort, '127.0.0.1') - await new Promise((resolve) => socket.once('connect', () => resolve())) - socket.on('error', () => {}) - // Why: the server's automatic 100 Continue proves the request is parsed and in flight. - const continued = new Promise((resolve) => socket.once('data', () => resolve())) - socket.write( - 'POST /unanswered HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 1\r\nExpect: 100-continue\r\n\r\n' - ) - await continued - - await transport.stop() - socket.destroy() - }) - it('is safe to stop without starting', async () => { const { transport } = await createTransport() await transport.stop() @@ -687,23 +669,6 @@ describe('WebSocketTransport', () => { expect(transport.resolvedPort).toBe(fallbackPort) }) - it('fails closed when a managed tunnel requires the configured port', async () => { - const preferredHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) - transports.push(preferredHolder) - await preferredHolder.start() - const transport = new WebSocketTransport({ - host: '127.0.0.1', - port: preferredHolder.resolvedPort, - fallbackPort: await reserveFreePort(), - preferPinnedPort: true, - strictPort: true - }) - transports.push(transport) - - await expect(transport.start()).rejects.toMatchObject({ code: 'EADDRINUSE' }) - expect(transport.resolvedHost).toBeNull() - }) - it('binds the preferred port when the persisted fallback is taken', async () => { const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(fallbackHolder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 81e76b1bb09..3ba8a17fe60 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -4,22 +4,22 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod import { WebSocketServer, type WebSocket } from 'ws' import type { RpcTransport } from './transport' import { createStaticWebClientHandler } from './static-web-client-handler' -import { - attachNodeWebSocketLifecycle, - clearNodeWebSocketPreAuthTimer, - rejectNodeWebSocketOverCapacity, - stopNodeWebSocketTransport, - type WebSocketConnectionCloseHandler, - type WebSocketMessageHandler -} from './node-websocket-lifecycle' import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' -import { - WEBSOCKET_TRANSPORT_MAX_CONNECTIONS, - WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES, - WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS -} from './websocket-transport-limits' +const MAX_WS_MESSAGE_BYTES = 1024 * 1024 +// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs. +const MAX_WS_CONNECTIONS = 128 +// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound. +const MAX_TCP_CONNECTIONS = MAX_WS_CONNECTIONS * 2 const PRE_AUTH_TIMEOUT_MS = 10_000 +type WebSocketMessagePayload = string | Uint8Array +type WebSocketMessageHandler = { + bivarianceHack( + msg: WebSocketMessagePayload, + reply: (response: string) => void, + ws: WebSocket + ): void +}['bivarianceHack'] // Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~60s (clients auto-pong per RFC 6455), since a reap needs consecutive unanswered probes rather than one (STA-3320). const HEARTBEAT_INTERVAL_MS = 15_000 @@ -41,8 +41,6 @@ export type WebSocketTransportOptions = { fallbackPort?: number // Why: serve --port clients dial the pinned port; prefer it first so a stale fallback can't steal the pin (issue #8535). Default keeps fallback-first (STA-1511). preferPinnedPort?: boolean - // Why: managed SSH tunnels dial one configured remote port and cannot follow a fallback. - strictPort?: boolean } export class WebSocketTransport implements RpcTransport { @@ -55,11 +53,12 @@ export class WebSocketTransport implements RpcTransport { private readonly staticRoot: string | undefined private readonly fallbackPort: number | undefined private readonly preferPinnedPort: boolean - private readonly strictPort: boolean private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private messageHandler: WebSocketMessageHandler | null = null - private connectionCloseHandler: WebSocketConnectionCloseHandler | null = null + private connectionCloseHandler: + | ((clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void) + | null = null // Why: maps each socket to its authenticated clientId so close can report which device disconnected. private wsClientIds = new Map() private heartbeatConnections = new Set() @@ -75,8 +74,7 @@ export class WebSocketTransport implements RpcTransport { preAuthTimeoutMs, staticRoot, fallbackPort, - preferPinnedPort, - strictPort + preferPinnedPort }: WebSocketTransportOptions) { this.host = host this.port = port @@ -85,13 +83,12 @@ export class WebSocketTransport implements RpcTransport { this.heartbeat = new RemoteRuntimeServerHeartbeat( heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS, heartbeatNow, - WEBSOCKET_TRANSPORT_MAX_CONNECTIONS + MAX_WS_CONNECTIONS ) this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS this.staticRoot = staticRoot this.fallbackPort = fallbackPort this.preferPinnedPort = preferPinnedPort === true - this.strictPort = strictPort === true } onMessage(handler: WebSocketMessageHandler): void { @@ -99,13 +96,15 @@ export class WebSocketTransport implements RpcTransport { } // Why: pass the closing `ws` and whether other sockets share its deviceToken, so client-scoped teardown fires only on the last disconnect. - onConnectionClose(handler: WebSocketConnectionCloseHandler): void { + onConnectionClose( + handler: (clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void + ): void { this.connectionCloseHandler = handler } setClientId(ws: WebSocket, clientId: string): void { this.wsClientIds.set(ws, clientId) - clearNodeWebSocketPreAuthTimer(ws, this.preAuthTimers) + this.clearPreAuthTimer(ws) } terminateClientConnections(clientId: string): number { @@ -138,10 +137,6 @@ export class WebSocketTransport implements RpcTransport { if (this.wss) { return } - if (this.strictPort) { - await this.tryListen(this.port) - return - } // Why: bind a persisted fallback first so devices paired to it aren't stranded (STA-1511); serve --port flips to pinned-first (issue #8535); on failure each candidate falls through to OS-assigned port 0. const persistedFallbackPort = @@ -197,16 +192,16 @@ export class WebSocketTransport implements RpcTransport { }) // Why: the WS cap applies only post-upgrade; a separate TCP cap bounds raw/pre-upgrade descriptor use. - httpServer.maxConnections = WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS + httpServer.maxConnections = MAX_TCP_CONNECTIONS const wss = new WebSocketServer({ server: httpServer, - maxPayload: WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES + maxPayload: MAX_WS_MESSAGE_BYTES }) wss.on('connection', (ws) => { - if (wss.clients.size > WEBSOCKET_TRANSPORT_MAX_CONNECTIONS) { - rejectNodeWebSocketOverCapacity(ws) + if (wss.clients.size > MAX_WS_CONNECTIONS) { + this.rejectOverCapacity(ws) return } this.handleConnection(ws) @@ -216,30 +211,130 @@ export class WebSocketTransport implements RpcTransport { this.wss = wss } + // Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing. + private rejectOverCapacity(ws: WebSocket): void { + ws.on('error', () => {}) + ws.close(1013, 'Maximum connections reached') + const terminateTimer = setTimeout(() => ws.terminate(), 1_000) + terminateTimer.unref?.() + ws.once('close', () => clearTimeout(terminateTimer)) + } + async stop(): Promise { const wss = this.wss const httpServer = this.httpServer this.wss = null this.httpServer = null - await stopNodeWebSocketTransport({ - wss, - httpServer, - heartbeat: this.heartbeat, - heartbeatConnections: this.heartbeatConnections - }) + this.heartbeat.stop() + this.heartbeatConnections.clear() + + if (wss) { + for (const client of wss.clients) { + // Why: a half-open mobile socket may never answer a close frame, which keeps httpServer.close pending. + client.terminate() + } + wss.close() + } + + if (httpServer) { + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } } + // Why: WS connections are long-lived and multiplex many RPCs by `id`; auth and dispatch are delegated to the message handler. private handleConnection(ws: WebSocket): void { - attachNodeWebSocketLifecycle({ - ws, - heartbeat: this.heartbeat, - preAuthTimeoutMs: this.preAuthTimeoutMs, - preAuthTimers: this.preAuthTimers, - clientIds: this.wsClientIds, - heartbeatConnections: this.heartbeatConnections, - getMessageHandler: () => this.messageHandler, - getConnectionCloseHandler: () => this.connectionCloseHandler - }) + let finalized = false + const onPong = (): void => { + this.heartbeat.noteAlive(ws) + } + const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { + // Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request. + this.heartbeat.noteAlive(ws) + const msg = + typeof data === 'string' + ? data + : isBinary + ? new Uint8Array(data as Buffer) + : data.toString() + this.messageHandler?.( + msg, + (response) => { + // Why: mobile clients disconnect often; guard the write so we don't throw on a dead socket. + if (ws.readyState === ws.OPEN) { + ws.send(response) + } + }, + ws + ) + } + const onError = (): void => { + // Why: close isn't guaranteed after every error path; finalize here too so pre-auth E2EE state and connection ids can't leak. + finalizeConnection() + ws.close() + } + const finalizeConnection = (): void => { + if (finalized) { + return + } + finalized = true + ws.off('pong', onPong) + ws.off('message', onMessage) + ws.off('close', finalizeConnection) + ws.off('error', onError) + this.clearPreAuthTimer(ws) + this.heartbeatConnections.delete(ws) + if (this.heartbeatConnections.size === 0) { + this.heartbeat.stop() + } + const clientId = this.wsClientIds.get(ws) ?? null + this.wsClientIds.delete(ws) + const hasOtherConnections = + clientId !== null && Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) + } + + const preAuthTimer = setTimeout(() => { + if (!this.wsClientIds.has(ws)) { + // Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake. + ws.terminate() + } + }, this.preAuthTimeoutMs) + if (typeof preAuthTimer.unref === 'function') { + preAuthTimer.unref() + } + this.preAuthTimers.set(ws, preAuthTimer) + + ws.on('pong', onPong) + ws.on('message', onMessage) + + // Why: clean up connection-scoped state (e.g. mobile-fit overrides) so a dropped phone doesn't leave orphaned phone-fit on desktop. + ws.on('close', finalizeConnection) + ws.on('error', onError) + + // Why: install lifecycle ownership before periodic heartbeat ticks can observe this socket. + this.heartbeatConnections.add(ws) + this.heartbeat.noteAlive(ws) + if (this.heartbeatConnections.size === 1) { + // Unauthenticated sockets are protected by the pre-auth timeout; heartbeat probes begin only + // after E2EE binds a client id, avoiding control frames during the handshake. + this.heartbeat.start(() => this.wsClientIds.keys()) + } + } + + private clearPreAuthTimer(ws: WebSocket): void { + const timer = this.preAuthTimers.get(ws) + if (timer) { + clearTimeout(timer) + this.preAuthTimers.delete(ws) + } } } diff --git a/src/main/runtime/runtime-environment-identity-verification.ts b/src/main/runtime/runtime-environment-identity-verification.ts deleted file mode 100644 index b23f0658706..00000000000 --- a/src/main/runtime/runtime-environment-identity-verification.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - getPreferredPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { verifyRemotePairingRuntimeStatus } from '../../shared/remote-pairing-verification' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import type { RuntimeStatus } from '../../shared/runtime-types' - -export async function verifyRuntimeEnvironmentIdentity( - environment: KnownRuntimeEnvironment, - options: { endpoint?: string; signal?: AbortSignal } = {} -) { - options.signal?.throwIfAborted() - const verifiedPairing = { - ...getPreferredPairingOffer(environment), - ...(options.endpoint ? { endpoint: options.endpoint } : {}) - } - const response = await sendRemoteRuntimeRequest( - verifiedPairing, - 'status.get', - undefined, - 15_000, - undefined, - options.signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - options.signal?.throwIfAborted() - if (!response.ok) { - throw new Error(`Runtime identity verification failed: ${response.error.message}`) - } - const status = verifyRemotePairingRuntimeStatus(response.result) - if (!status.ok) { - throw new Error(status.message) - } - const runtimeId = status.runtimeStatus.runtimeId - if ( - response._meta.runtimeId !== runtimeId || - (environment.runtimeId !== null && runtimeId !== environment.runtimeId) || - (environment.pairedDeviceId !== undefined && - status.runtimeStatus.pairedDeviceId !== undefined && - status.runtimeStatus.pairedDeviceId !== environment.pairedDeviceId) - ) { - throw new Error('The endpoint does not match this paired runtime identity.') - } - return { verifiedPairing, verifiedRuntimeId: runtimeId, runtimeStatus: status.runtimeStatus } -} diff --git a/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts b/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts deleted file mode 100644 index 9a950a47354..00000000000 --- a/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RuntimeLegacyWorkerTerminalRecoveryController } from './runtime-legacy-worker-terminal-recovery-controller' -import type { LegacyWorkerRecoveryPorts } from './runtime-legacy-worker-terminal-recovery-types' -import type { - LegacyWorkerTerminalRecoveryCandidate, - LegacyWorkerTerminalRecoveryPlan -} from './orchestration/orchestration-legacy-worker-terminal-recovery' - -function candidate(ptyId: string, dispatchId: string): LegacyWorkerTerminalRecoveryCandidate { - return { - dispatchId, - dispatchStatus: 'dispatched', - contractVersion: 1, - taskId: 'task', - worktreeId: 'worktree', - terminalHandle: 'handle', - paneKey: 'tab:leaf', - tabId: 'tab', - leafId: 'leaf', - processIncarnation: `${ptyId}:incarnation`, - ptyId, - incarnationId: 'incarnation' - } -} - -function setup() { - const ports = { - preparePlan: vi.fn((): LegacyWorkerTerminalRecoveryPlan => ({ - candidates: [], - ambiguousDispatchIds: [] - })), - resolveWorkspace: vi.fn(async () => { - throw new Error('unused') - }), - refreshInventory: vi.fn(async () => null), - runMutation: (_worktreeId: string, operation: () => Promise) => operation(), - getActivation: () => ({}), - hasExactPersistedSurface: () => false, - hasExactSurface: () => false, - adopt: vi.fn(async () => {}), - getRendererEpoch: () => 0, - reveal: vi.fn(async () => null), - onPtyExit: vi.fn(), - persist: vi.fn(async (): Promise> => new Set()), - rollback: vi.fn(), - reconcileMissing: () => false, - notifyResolution: vi.fn(), - canRecoverPersistentLocalPtys: () => true, - reconcileRequestedReleases: vi.fn(async (): Promise => undefined), - reconcile: vi.fn(async () => ({ - adoptedDispatchIds: [], - exitedDispatchIds: [], - deferredDispatchIds: [] - })), - updateRetry: vi.fn() - } satisfies LegacyWorkerRecoveryPorts - const controller = new RuntimeLegacyWorkerTerminalRecoveryController(ports) - return { controller, ports } -} -afterEach(() => vi.useRealTimers()) - -it('cancels both local and SSH retry timers and refuses later recovery work', async () => { - vi.useFakeTimers() - const { controller, ports } = setup() - const plan: LegacyWorkerTerminalRecoveryPlan = { - ambiguousDispatchIds: [], - candidates: [ - candidate('local-pty', 'local-dispatch'), - candidate('ssh:host@@remote-pty', 'remote-dispatch') - ] - } - const deferred = new Set(['local-dispatch', 'remote-dispatch']) - controller.updateRetry(plan, deferred, {}) - controller.updateRetry(plan, deferred, { connectionId: 'host' }) - expect(vi.getTimerCount()).toBe(2) - await controller.stop() - controller.updateRetry(plan, deferred, {}) - await vi.advanceTimersByTimeAsync(60_000) - expect(vi.getTimerCount()).toBe(0) - expect(ports.reconcile).not.toHaveBeenCalled() - await expect(controller.reconcile()).rejects.toThrow('recovery_stopped') -}) - -it('drains an active persistence pass but refuses a queued pass after shutdown', async () => { - const { controller, ports } = setup() - let finish!: (value: Set) => void - ports.persist.mockImplementationOnce( - () => - new Promise>((resolve) => { - finish = resolve - }) - ) - const first = controller.reconcile() - await vi.waitFor(() => expect(ports.persist).toHaveBeenCalledOnce()) - const queued = expect(controller.reconcile()).rejects.toThrow('recovery_stopped') - const settled = vi.fn() - const stopping = controller.stop().then(settled) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finish(new Set()) - await Promise.all([first, queued, stopping]) - expect(ports.preparePlan).toHaveBeenCalledOnce() - expect(ports.persist).toHaveBeenCalledOnce() -}) - -it('holds shutdown until requested-release reconciliation finishes', async () => { - const { controller, ports } = setup() - let finish!: () => void - ports.reconcileRequestedReleases.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = () => resolve(undefined) - }) - ) - const recovery = controller.reconcile() - await vi.waitFor(() => expect(ports.reconcileRequestedReleases).toHaveBeenCalledOnce()) - const settled = vi.fn() - const stopping = controller.stop().then(settled) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finish() - await Promise.all([recovery, stopping]) - expect(settled).toHaveBeenCalledOnce() -}) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts index 44a535c8127..59e29a1858d 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts @@ -32,31 +32,12 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { private readonly retries = new Map() private readonly receiptEpochByPane = new Map() private readonly recoveredPtys = new Set() - private readonly backgroundWork = new Set>() - private stopped = false constructor(private readonly ports: LegacyWorkerRecoveryPorts) {} - /** Cancels retries and refuses new passes; resolves once running and released work drained. */ - async stop(): Promise { - this.stopped = true - this.cancelAllRetries() - await this.queue - await Promise.all(this.backgroundWork) - } - - /** Work a pass starts without awaiting; shutdown still waits for it. */ - trackBackgroundWork(work: Promise): void { - this.backgroundWork.add(work) - void work.finally(() => this.backgroundWork.delete(work)) - } - reconcile( options: LegacyWorkerRecoveryOptions = {} ): Promise { - if (this.stopped) { - return Promise.reject(new Error('worker_terminal_recovery_stopped')) - } let resolveResult!: (result: LegacyWorkerTerminalRecoveryResult) => void let rejectResult!: (error: unknown) => void const result = new Promise((resolve, reject) => { @@ -65,9 +46,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { }) const run = this.queue.then(async () => { try { - if (this.stopped) { - throw new Error('worker_terminal_recovery_stopped') - } resolveResult(await runLegacyWorkerTerminalRecovery(this, this.ports, options)) } catch (error) { rejectResult(error) @@ -99,9 +77,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { deferredDispatchIds: ReadonlySet, options: LegacyWorkerRecoveryOptions ): void { - if (this.stopped) { - return - } const scopeKey = options.connectionId ? `ssh:${options.connectionId}` : 'local' const hasDeferredWorker = plan.candidates.some((candidate) => { const sshPty = parseAppSshPtyId(candidate.ptyId) @@ -150,7 +125,7 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { } private armRetry(scopeKey: string, retry: RecoveryRetry): void { - if (this.stopped || retry.timer) { + if (retry.timer) { return } const delayMs = Math.min(1_000 * 2 ** retry.attempt, 30_000) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts index adbb89512d2..6bbe3b2ed2f 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts @@ -110,10 +110,8 @@ export async function runLegacyWorkerTerminalRecovery( } ports.updateRetry(plan, deferredDispatchIds, options) // Why: releases may only finish after the owning provider's terminals are rediscovered. - controller.trackBackgroundWork( - ports.reconcileRequestedReleases().catch((error) => { - console.warn('[orchestration] worker terminal release reconciliation failed', { error }) - }) - ) + void ports.reconcileRequestedReleases().catch((error) => { + console.warn('[orchestration] worker terminal release reconciliation failed', { error }) + }) return result } diff --git a/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts b/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts deleted file mode 100644 index 80f30b64342..00000000000 --- a/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection' -import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' - -it.each(['pty', 'leaf', 'unknown', 'disconnected'] as const)( - 'publishes only the handle-owning incarnation for %s', - (kind) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: projection reads only ptyId, connected and incarnationId from the record. - const pty = { - ptyId: 'live-pty', - connected: kind !== 'disconnected', - incarnationId: kind === 'unknown' ? null : 'live-incarnation' - } as RuntimePtyWorktreeRecord - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every host member this projection path calls. - const host = { - tabs: new Map(), - leaves: new Map(kind === 'leaf' ? [['leaf', { ptyId: pty.ptyId, connected: true }]] : []), - ptysById: new Map([[pty.ptyId, pty]]), - getLiveBrowserTabs: () => new Map(), - getProviderSessionRows: () => [], - getProviderSessionSnapshot: () => [], - getLeafKey: () => 'leaf', - findPty: () => pty, - getRetainedStatus: () => null, - getTrackedTitle: () => null, - issuePtyHandle: vi.fn(() => 'handle'), - recordPty: vi.fn(() => pty), - buildPtyStatus: () => ({}), - sanitizeGroups: () => [], - pruneGroupLayout: () => null, - collectTabIds: () => new Set() - } as unknown as RuntimeMobileSessionProjectionHost - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: one terminal tab is the only snapshot field this projection path reads. - const snapshot = { - worktree: 'workspace', - publicationEpoch: 'headless:epoch', - tabs: [ - { - type: 'terminal', - id: 'tab::leaf', - parentTabId: 'tab', - leafId: 'leaf', - ptyId: 'stale-pty', - incarnationId: 'stale-incarnation', - title: 'Shell', - isActive: true - } - ] - } as RuntimeMobileSessionTabsSnapshot - - const tab = projectRuntimeMobileSessionTabs(snapshot, host).tabs[0] - - if (kind === 'unknown' || kind === 'disconnected') { - expect(tab).not.toHaveProperty('incarnationId') - } else { - expect(tab).toHaveProperty('incarnationId', 'live-incarnation') - } - expect(tab).toHaveProperty('status', kind === 'disconnected' ? 'pending-handle' : 'ready') - if (kind !== 'disconnected') { - expect(host.issuePtyHandle).toHaveBeenCalledWith(pty) - } - } -) diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 969a2946c9a..db1ef0619ca 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -247,14 +247,17 @@ export function projectRuntimeMobileSessionTabs( } : null // Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable. - const terminalPty = liveLeafPtyId - ? host.recordPty(liveLeafPtyId, snapshot.worktree, { - tabId: tab.parentTabId, - paneKey, - connected: true - }) + const terminalHandle = liveLeafPtyId + ? host.issuePtyHandle( + host.recordPty(liveLeafPtyId, snapshot.worktree, { + tabId: tab.parentTabId, + paneKey, + connected: true + }) + ) : livePty - const terminalHandle = terminalPty ? host.issuePtyHandle(terminalPty) : null + ? host.issuePtyHandle(livePty) + : null const projectedAgentStatus = agentStatus ?? host.buildPtyStatus( @@ -287,8 +290,6 @@ export function projectRuntimeMobileSessionTabs( leafId: tab.leafId, title, ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), - // Bind identity to the handle's live owner, never a stale persisted surface. - ...(terminalPty?.incarnationId ? { incarnationId: terminalPty.incarnationId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), ...clientAgentStatus, diff --git a/src/main/runtime/runtime-rpc-required-ws-port.test.ts b/src/main/runtime/runtime-rpc-required-ws-port.test.ts deleted file mode 100644 index 5a264fcc0c0..00000000000 --- a/src/main/runtime/runtime-rpc-required-ws-port.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { mkdtempSync, readdirSync } from 'node:fs' -import { createServer, type Server } from 'node:net' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { OrcaRuntimeRpcServer } from './runtime-rpc' -import { readWsFallbackPort } from './rpc/ws-fallback-port-store' - -vi.mock('../git/worktree', () => ({ - listWorktrees: vi.fn().mockResolvedValue([]), - listWorktreesStrict: vi.fn().mockResolvedValue([]) -})) - -const holders: Server[] = [] - -afterEach(async () => { - await Promise.all( - holders.splice(0).map((holder) => new Promise((resolve) => holder.close(() => resolve()))) - ) -}) - -async function occupyLoopbackPort(): Promise { - const holder = createServer() - holders.push(holder) - await new Promise((resolve) => holder.listen(0, '127.0.0.1', resolve)) - const address = holder.address() - if (!address || typeof address === 'string') { - throw new Error('holder did not bind a TCP port') - } - return address.port -} - -function boundWsPort(server: OrcaRuntimeRpcServer): number | null { - const endpoint = server.getWebSocketEndpoint() - return endpoint ? Number(new URL(endpoint).port) : null -} - -describe('OrcaRuntimeRpcServer required WebSocket port', () => { - it('fails startup instead of falling back when the required port is taken', async () => { - const port = await occupyLoopbackPort() - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath, - enableWebSocket: true, - wsPort: port, - preferPinnedWsPort: true, - requirePinnedWsPort: true - }) - - try { - await expect(server.start()).rejects.toMatchObject({ code: 'EADDRINUSE' }) - expect(boundWsPort(server)).toBeNull() - expect(readWsFallbackPort(userDataPath)).toBeUndefined() - // Why: the failed start must also release the Unix socket it opened first, or the endpoint leaks. - expect(readdirSync(userDataPath).filter((name) => name.endsWith('.sock'))).toEqual([]) - } finally { - await server.stop() - } - }) - - it('still falls back for a pinned but not required port', async () => { - const port = await occupyLoopbackPort() - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath, - enableWebSocket: true, - wsPort: port, - preferPinnedWsPort: true - }) - - await server.start() - try { - const resolvedPort = boundWsPort(server) - expect(resolvedPort).not.toBe(port) - expect(readWsFallbackPort(userDataPath)).toBe(resolvedPort) - } finally { - await server.stop() - } - }) -}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 4c595b3bbab..9cfe31251b6 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -90,9 +90,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { port: this.wsPort, preferPinnedPort: this.preferPinnedWsPort, // Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E). - ...(this.wsPort !== 0 && !this.requirePinnedWsPort - ? { fallbackPort: readWsFallbackPort(this.userDataPath) } - : {}) + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) if (this.wsPort !== 0 && transport.resolvedPort !== this.wsPort) { writeWsFallbackPort(this.userDataPath, transport.resolvedPort) @@ -100,10 +98,6 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { activeTransports.push(transport) transportsMeta.push({ kind: 'websocket', endpoint }) } catch (error) { - if (this.requirePinnedWsPort) { - await socketTransport.stop().catch(() => {}) - throw error - } // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) this.mobileSocketWiring = null @@ -180,7 +174,6 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { host: options.host, port: options.port, staticRoot: this.webClientRoot, - ...(this.requirePinnedWsPort ? { strictPort: true } : {}), ...(options.fallbackPort !== undefined ? { fallbackPort: options.fallbackPort } : {}), ...(options.preferPinnedPort ? { preferPinnedPort: true } : {}) }) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts index da517dd9d92..4923dec1dec 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts @@ -36,8 +36,6 @@ export type OrcaRuntimeRpcServerOptions = { wsPort?: number // Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535). preferPinnedWsPort?: boolean - // Why: a managed SSH tunnel forwards exactly this port, so a fallback bind must fail startup instead. - requirePinnedWsPort?: boolean // Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired. // Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing. exposeNetworkByDefault?: boolean diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index 20ddbf70e38..e7f56ceed13 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -38,7 +38,6 @@ export class RuntimeRpcState { protected readonly enableWebSocket: boolean protected readonly wsPort: number protected readonly preferPinnedWsPort: boolean - protected readonly requirePinnedWsPort: boolean protected readonly exposeNetworkByDefault: boolean protected readonly pinnedBindHost: string | null protected readonly webClientRoot: string | undefined @@ -101,7 +100,6 @@ export class RuntimeRpcState { enableWebSocket = false, wsPort = DEFAULT_WS_PORT, preferPinnedWsPort = false, - requirePinnedWsPort = false, exposeNetworkByDefault = false, pinnedBindHost, webClientRoot, @@ -118,7 +116,6 @@ export class RuntimeRpcState { this.enableWebSocket = enableWebSocket this.wsPort = wsPort this.preferPinnedWsPort = preferPinnedWsPort - this.requirePinnedWsPort = requirePinnedWsPort this.exposeNetworkByDefault = exposeNetworkByDefault this.pinnedBindHost = pinnedBindHost ?? null this.webClientRoot = webClientRoot diff --git a/src/main/ssh/orcad-activation-crash-recovery.test.ts b/src/main/ssh/orcad-activation-crash-recovery.test.ts deleted file mode 100644 index 8a61515f1a7..00000000000 --- a/src/main/ssh/orcad-activation-crash-recovery.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' -import type * as VersionedInstall from './ssh-relay-versioned-install' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn() -})) -vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn() -})) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn() -})) -vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ - ...(await importOriginal()), - readLocalFullVersion: () => '0.2.0+bb01' -})) -vi.mock('./orcad-remote-install', () => ({ installOrcadBundle: vi.fn() })) -vi.mock('./orcad-remote-preflight', () => ({ preflightInstalledOrcad: vi.fn() })) -vi.mock('./orcad-local-build-hash', () => ({ - computeLocalOrcadBuildHash: () => 'abc123def4567890' -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' -import { deployOrcad } from './orcad-remote-deploy' -import { rollbackOrcad } from './orcad-remote-rollback' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import { - BUILD_HASH, - FakeOrcadHost, - NEW, - OLD, - type CrashMode -} from './orcad-activation-host-test-harness' - -let host = new FakeOrcadHost() - -const slot = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u', - nodePath: '/usr/bin/node', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777, - readinessTimeoutMs: 50, - sleep: async () => {}, - now: () => new Date('2026-02-02T00:00:00.000Z') -} -const census = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } - -const deploy = (): Promise => - deployOrcad({ ...slot, localOrcadDir: '/local/out/orcad', target: 'linux-x64-glibc', census }) -const rollback = (): Promise => - rollbackOrcad({ - ...slot, - record: FakeOrcadHost.newRecord(), - census, - targetBuildHash: BUILD_HASH, - targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, - terminalsStartedSince: async () => 0 - }) - -beforeEach(() => { - vi.clearAllMocks() - vi.mocked(execCommand).mockImplementation(async (_conn, command) => host.exec(command)) - vi.mocked(acquireInstallLock).mockImplementation(async () => host.acquireFence()) - vi.mocked(writeAtomicOrcadRemoteRecord).mockImplementation(async (_target, path, contents) => - host.write(path, contents) - ) -}) - -/** The invariant: the host serves exactly the slot its record names, on state that slot reads. */ -function expectExactlyTheRecordedSlot(): void { - const active = host.activeVersion() - expect([...host.alive]).toEqual(active ? [active] : []) - expect(host.isReadableBy(active)).toBe(true) - expect(host.journal).toBeNull() - expect(host.fence).toBe(false) -} - -/** Unconfirmed termination never deletes a slot directory or a snapshot. */ -function expectNoSlotOrSnapshotRemoved(): void { - const removals = host.commands.filter((command) => /\brm -r?f\b/u.test(command)) - for (const command of removals) { - // A file inside a slot (a consumed stop request) may go; the slot directory never does. - expect(command).not.toMatch(/rm -r?f '[^']*\/orcad-\d[^'/]*\/?'/u) - expect(command).not.toMatch(/rm -r?f '[^']*orcad-state-snapshots/u) - } -} - -function countMutations( - scenario: () => FakeOrcadHost, - run: () => Promise -): Promise { - host = scenario() - return run().then(() => host.mutations) -} - -const scenarios: [string, () => FakeOrcadHost, () => Promise][] = [ - ['an update over an incumbent', FakeOrcadHost.deployedOld, deploy], - ['a first activation', () => new FakeOrcadHost(), deploy], - ['a rollback', FakeOrcadHost.activatedNew, rollback] -] - -describe.each(scenarios)('%s interrupted at every mutation', (_name, scenario, run) => { - it('completes cleanly when nothing interrupts it', async () => { - host = scenario() - await run() - expectExactlyTheRecordedSlot() - expect(host.activeVersion()).toBe(run === rollback ? OLD : NEW) - }) - - it.each(['before', 'after'])( - 'recovers to exactly one recorded slot when the host answer is lost %s applying it', - async (mode) => { - const total = await countMutations(scenario, run) - expect(total).toBeGreaterThan(3) - for (let crashAt = 1; crashAt <= total; crashAt += 1) { - host = scenario() - host.crashAt = crashAt - host.crashMode = mode - await run().catch(() => undefined) - host.crashAt = null - const result = await recoverInterruptedOrcadActivation({ - ...slot, - terminalsStartedSince: async () => 0 - }) - expect(['recovered', 'none'], `mutation ${crashAt}`).toContain(result.outcome) - expectExactlyTheRecordedSlot() - expectNoSlotOrSnapshotRemoved() - } - } - ) -}) - -describe('recovery refusals keep the fence', () => { - async function interruptedAfterCandidateLaunch(): Promise { - host = FakeOrcadHost.deployedOld() - const total = await countMutations(FakeOrcadHost.deployedOld, deploy) - host = FakeOrcadHost.deployedOld() - // The last three mutations are: candidate-ready journal, record, fence release. - host.crashAt = total - 2 - host.crashMode = 'before' - await deploy().catch(() => undefined) - host.crashAt = null - expect(host.alive.has(NEW)).toBe(true) - } - - it('does not restore state over terminals started since the interrupted change', async () => { - await interruptedAfterCandidateLaunch() - const result = await recoverInterruptedOrcadActivation({ - ...slot, - terminalsStartedSince: async () => 2 - }) - expect(result).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_recovery_orphans_live_terminals' - }) - expect(host.fence).toBe(true) - expect(host.journal).not.toBeNull() - expect(host.alive.size).toBe(0) - }) - - it.each([undefined, null])('keeps changed state when the fresh census is %s', async (started) => { - await interruptedAfterCandidateLaunch() - const result = await recoverInterruptedOrcadActivation({ - ...slot, - ...(started === undefined ? {} : { terminalsStartedSince: async () => started }) - }) - expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_recovery_census_required' }) - expect(host.journal).not.toBeNull() - }) - - it('never treats an unverifiable incumbent as exited', async () => { - host = FakeOrcadHost.deployedOld() - host.crashAt = 3 - await deploy().catch(() => undefined) - host.crashAt = null - host.pidFiles.delete(OLD) - host.alive.delete(OLD) - const result = await recoverInterruptedOrcadActivation({ ...slot }) - expect(result).toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expect(host.fence).toBe(true) - expect(host.alive.size).toBe(0) - }) - - it('reports a fresh fence as pending instead of taking it over', async () => { - host = FakeOrcadHost.deployedOld() - host.crashAt = 3 - await deploy().catch(() => undefined) - host.crashAt = null - const journal = host.journal - expect(journal).not.toBeNull() - const { RemoteInstallLockBusyError } = await vi.importActual( - './ssh-relay-install-lock' - ) - vi.mocked(acquireInstallLock).mockRejectedValueOnce(new RemoteInstallLockBusyError('/l', 0)) - expect(await recoverInterruptedOrcadActivation({ ...slot })).toMatchObject({ - outcome: 'pending' - }) - expect(host.journal).toBe(journal) - }) - - it('keeps a journal this client cannot read', async () => { - host = FakeOrcadHost.deployedOld() - host.journal = JSON.stringify({ schemaVersion: 1, operation: 'decommission' }) - host.fence = true - expect(await recoverInterruptedOrcadActivation({ ...slot })).toMatchObject({ - outcome: 'refused', - code: 'orcad_recovery_unverifiable' - }) - expect(host.fence).toBe(true) - }) - - it('drops a fence whose release was cut short after the journal went', async () => { - host = FakeOrcadHost.deployedOld() - host.fence = true - expect(await recoverInterruptedOrcadActivation({ ...slot })).toEqual({ outcome: 'none' }) - expect(host.fence).toBe(false) - }) -}) diff --git a/src/main/ssh/orcad-activation-host-test-harness.ts b/src/main/ssh/orcad-activation-host-test-harness.ts deleted file mode 100644 index 2ce1353529d..00000000000 --- a/src/main/ssh/orcad-activation-host-test-harness.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * A stateful fake orcad host for crash-recovery tests: slot processes, PID files, the shared - * profile state, snapshots, the activation record, the journal and its fence. It can drop the - * connection at any numbered mutation, before or after the host applied it. - */ -import { - emptyOrcadActivationRecord, - parseOrcadActivationRecord, - withActivatedVersion, - type OrcadActivationRecord -} from './orcad-activation-record' - -export const OLD = '0.1.0+aa01' -export const NEW = '0.2.0+bb01' -export const BUILD_HASH = 'abc123def4567890' -/** State the incoming build migrates on load; the outgoing build cannot read it. */ -const MIGRATION = '|migrated-by-new' - -export type CrashMode = 'before' | 'after' - -export class FakeOrcadHost { - record: string | null = null - journal: string | null = null - fence = false - readonly alive = new Set() - readonly pidFiles = new Set() - data = 'profiles-v1' - readonly snapshots = new Map() - readonly commands: string[] = [] - mutations = 0 - crashAt: number | null = null - crashMode: CrashMode = 'before' - /** How the slot's managed-stop command behaves: orcad exits, or keeps serving. */ - managedStop: 'exits' | 'stays' | 'stays-dispatched' = 'exits' - readonly dispatched = new Set() - - static deployedOld(): FakeOrcadHost { - const host = new FakeOrcadHost() - host.record = JSON.stringify( - withActivatedVersion(emptyOrcadActivationRecord(), OLD, null, new Date(0)) - ) - host.alive.add(OLD) - host.pidFiles.add(OLD) - return host - } - - static activatedNew(): FakeOrcadHost { - const host = FakeOrcadHost.deployedOld() - host.alive.clear() - host.pidFiles.add(NEW) - host.alive.add(NEW) - host.snapshots.set('pre-0.2.0+bb01-1000', host.data) - host.data += MIGRATION - host.record = JSON.stringify(FakeOrcadHost.newRecord()) - return host - } - - static newRecord(): OrcadActivationRecord { - return withActivatedVersion( - { - ...emptyOrcadActivationRecord(), - active: OLD, - activatedAt: new Date(0).toISOString() - }, - NEW, - { - dirName: 'pre-0.2.0+bb01-1000', - takenBeforeVersion: NEW, - readableByVersion: OLD, - takenAt: new Date(1000).toISOString() - }, - new Date(1000) - ) - } - - activeVersion(): string | null { - const parsed = parseOrcadActivationRecord(this.record) - return parsed.state === 'ok' ? parsed.record.active : null - } - - /** Every state the old build can read: the pre-migration profile. */ - isReadableBy(version: string | null): boolean { - return version !== OLD || !this.data.includes(MIGRATION) - } - - private mutate(apply: () => T): T { - this.mutations += 1 - if (this.crashAt !== this.mutations) { - return apply() - } - if (this.crashMode === 'after') { - apply() - } - throw Object.assign(new Error(`connection lost at mutation ${this.mutations}`), { - sshChannelCloseConfirmed: false - }) - } - - write(path: string, contents: string): void { - this.mutate(() => { - if (path.endsWith('transaction.json')) { - this.journal = contents - } else if (path.endsWith('orcad-active.json')) { - this.record = contents - } - }) - } - - acquireFence(): void { - this.fence = true - } - - exec(command: string): string { - this.commands.push(command) - const version = /\/orcad-(\d+\.\d+\.\d+\+[0-9a-f]+)/u.exec(command)?.[1] ?? null - const snapshot = /orcad-state-snapshots\/([A-Za-z0-9][A-Za-z0-9.+-]*)/u.exec(command)?.[1] - if (command.includes('__ORCAD_RECORD_PRESENT__') && command.includes('orcad.lock')) { - const owner = [...this.alive][0] - return owner - ? `__ORCAD_RECORD_PRESENT__\n${JSON.stringify(lockRecord(owner))}` - : '__ORCAD_RECORD_ABSENT__\n' - } - if (command.includes('-managed-stop ') && version) { - return this.runManagedStopCommand(command, version) - } - if (command.includes('__ORCAD_RECORD_PRESENT__')) { - const value = command.includes('transaction.json') ? this.journal : this.record - return value === null ? '__ORCAD_RECORD_ABSENT__\n' : `__ORCAD_RECORD_PRESENT__\n${value}` - } - if (command.includes('echo LOCKED || echo OPEN')) { - return this.fence ? 'LOCKED' : 'OPEN' - } - if (command.startsWith('rm -f') && command.includes('transaction.json')) { - return this.mutate(() => { - this.journal = null - this.fence = false - return '' - }) - } - if (command.includes('__ORCAD_BUILD_HASH__')) { - return `__ORCAD_BUILD_HASH__ ${BUILD_HASH}\n` - } - if (command.includes('orca-runtime.json')) { - return this.alive.size > 0 ? 'LIVE orcad.lock 1' : 'CLEAR' - } - if (command.includes('echo LIVE;') && version) { - if (this.alive.has(version)) { - return 'LIVE' - } - return this.pidFiles.has(version) ? 'DEAD' : 'UNKNOWN' - } - if (command.includes('kill -TERM') && version) { - if (!this.pidFiles.has(version)) { - return 'NO_PID' - } - return this.mutate(() => (this.alive.delete(version) ? 'STOPPED' : 'ALREADY_EXITED')) - } - if (command.includes('nohup') && version) { - return this.mutate(() => { - this.pidFiles.add(version) - // The instance lock and port admit one owner; a second launch never becomes ready. - if (this.alive.size === 0) { - this.alive.add(version) - if (version === NEW && !this.data.includes(MIGRATION)) { - this.data += MIGRATION - } - } - return '9999' - }) - } - if (command.startsWith('head -c ') && version) { - return this.alive.has(version) ? readyLine(version) : '' - } - if (command.includes('echo PRESENT') && snapshot) { - return this.snapshots.has(snapshot) ? 'PRESENT' : 'ABSENT' - } - if (command.includes('verdict=UNCHANGED') && snapshot) { - return this.snapshots.get(snapshot) === this.data ? 'UNCHANGED' : 'CHANGED' - } - if (command.includes('-cf') && snapshot) { - return this.mutate(() => { - if (this.data === '') { - return 'EMPTY' - } - this.snapshots.set(snapshot, this.data) - return 'CAPTURED' - }) - } - if (command.includes('.orcad-state-restore-stage') && snapshot) { - return this.mutate(() => { - const restored = this.snapshots.get(snapshot) - if (restored === undefined) { - return 'MISSING' - } - this.data = restored - return 'RESTORED' - }) - } - if (command.includes('then echo RESTORED')) { - return this.mutate(() => { - this.data = '' - return 'RESTORED' - }) - } - if (command.includes('stat -c %Y')) { - return 'UNKNOWN' - } - return '' - } - - /** The slot's `--complete-managed-stop` / `--cancel-managed-stop`, as orcad answers them. */ - private runManagedStopCommand(command: string, version: string): string { - const request = JSON.parse(command.slice(command.lastIndexOf(" '{") + 2, -1)) - if (command.includes('--cancel-managed-stop')) { - const outcome = this.dispatched.has(request.transactionId) ? 'dispatched' : 'canceled' - return JSON.stringify({ ...request, kind: 'orcad_managed_stop_cancellation', outcome }) - } - const verdict = this.mutate(() => { - if (this.managedStop === 'exits') { - this.dispatched.add(request.transactionId) - this.alive.delete(version) - } else if (this.managedStop === 'stays-dispatched') { - this.dispatched.add(request.transactionId) - } - return this.alive.has(version) ? 'live' : 'exited' - }) - return JSON.stringify({ - ...request, - kind: 'orcad_managed_stop_completion', - verdict, - receiptPersisted: verdict === 'exited', - ...(verdict === 'exited' ? { retirement: 'retired' } : {}) - }) - } -} - -function lockRecord(version: string) { - return { - pid: 1, - startedAtMs: null, - identity: 'uid-1000', - version, - acquiredAt: new Date(0).toISOString(), - nonce: `nonce-${version}` - } -} - -export function readyLine(version: string): string { - return JSON.stringify({ - type: 'orca_server_ready', - schemaVersion: 1, - runtimeId: 'r1', - boundEndpoint: 'ws://127.0.0.1:7777', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, - health: { - buildHash: BUILD_HASH, - buildVersion: version, - nodeVersion: '24.21.0', - nodeAbi: '137', - platform: 'linux', - arch: 'x64', - pid: 1, - stopRequests: 1, - terminalDaemon: { - state: 'live', - ownsFreshSessions: true, - pid: 2, - buildVersion: version, - entryPath: '/x/daemon-entry.js', - protocolVersion: 3, - selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 5 } - } - } - }) -} diff --git a/src/main/ssh/orcad-activation-lock.ts b/src/main/ssh/orcad-activation-lock.ts deleted file mode 100644 index 7a0a5e9a6e0..00000000000 --- a/src/main/ssh/orcad-activation-lock.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * One activation or rollback per host, and the fence an interrupted one leaves behind. - * - * The lock lives in the transaction root, so releasing it also removes the journal. A run - * that cannot prove the host is back to one serving slot retains both; only recovery, after - * the install lock's stale window, may take a retained fence over. - */ -import { - execOrcadRemote, - withoutAbortSignal, - type OrcadRemoteExecTarget -} from './orcad-remote-runtime-control' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { acquireInstallLock, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { removeRemoteFileCommand, removeRemoteTreeCommand } from './ssh-remote-commands' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - ORCAD_ACTIVATION_TRANSACTION_DIRNAME, - ORCAD_ACTIVATION_TRANSACTION_FILENAME -} from './orcad-activation-transaction' - -const ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS = 5 * 60_000 - -type OrcadActivationLockOptions = OrcadRemoteExecTarget & { remoteHome: string } - -export type OrcadActivationLockControl = { - /** Keep the fence if the run throws: a journal now describes host state. */ - retainOnError(): void - /** Keep the fence even on return: the host is not proven back to one serving slot. */ - retain(): void - /** The host is proven back on its recorded slot: release even if the run then throws. */ - recovered(): void -} - -export function orcadActivationTransactionRoot( - host: RemoteHostPlatform, - remoteHome: string -): string { - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_TRANSACTION_DIRNAME) -} - -/** Bounded so a crashed holder's lock goes stale long before a live holder could still be waiting. */ -export function resolveOrcadActivationReadinessTimeout( - configured: number | undefined, - fallback: number -): number { - const timeout = configured ?? fallback - if ( - !Number.isSafeInteger(timeout) || - timeout <= 0 || - timeout > ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS - ) { - throw new Error( - `orcad readiness timeout must be an integer from 1 to ${ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS}ms` - ) - } - return timeout -} - -export async function withOrcadActivationLock( - options: OrcadActivationLockOptions, - run: (control: OrcadActivationLockControl) => Promise -): Promise { - const lockRoot = orcadActivationTransactionRoot(options.host, options.remoteHome) - await acquireInstallLock(options.conn, lockRoot, options.host, { - signal: options.signal, - relayGcClaim: false, - // A retained fence means state ownership is unresolved. Age cannot make it safe. - allowStaleTakeover: false - }) - let retainOnError = false - let retain = false - try { - const result = await run({ - retainOnError: () => { - retainOnError = true - }, - retain: () => { - retain = true - }, - recovered: () => { - retainOnError = false - } - }) - if (!retain) { - await releaseActivationFence(options, lockRoot) - } - return result - } catch (error) { - // A remote mutation whose teardown is unconfirmed may still be running: keep its fence. - if (!retainOnError && !isUnconfirmedSshCommandTermination(error)) { - await releaseActivationFence(options, lockRoot).catch((releaseError: unknown) => { - console.warn( - `[orcad] Failed to release activation lock after an error: ${releaseError instanceof Error ? releaseError.message : String(releaseError)}` - ) - }) - } - throw error - } -} - -/** Takes over only a stale or previous-boot fence; a fresh one throws `RemoteInstallLockBusyError`. */ -export async function withStaleOrcadActivationRecoveryLock( - options: OrcadActivationLockOptions, - run: (control: Pick) => Promise -): Promise { - const lockRoot = orcadActivationTransactionRoot(options.host, options.remoteHome) - await acquireInstallLock(options.conn, lockRoot, options.host, { - signal: options.signal, - relayGcClaim: false, - allowStaleTakeover: true, - waitTimeoutMs: 0 - }) - let retain = false - // Any throw keeps the fence: recovery failed to prove one serving slot. - const result = await run({ retain: () => (retain = true) }) - if (!retain) { - await releaseActivationFence(options, lockRoot) - } - return result -} - -/** Whether any lock is held; a lost probe throws rather than reading as open. */ -export async function orcadActivationFenceExists( - options: OrcadActivationLockOptions -): Promise { - const lockDir = joinRemotePath( - options.host, - orcadActivationTransactionRoot(options.host, options.remoteHome), - RELAY_INSTALL_LOCK_NAME - ) - const answer = ( - await execOrcadRemote(options, probeInstallLockExistsCommand(options.host, lockDir)) - ).trim() - if (answer !== 'LOCKED' && answer !== 'OPEN') { - throw new Error('The activation fence probe returned no verifiable answer.') - } - return answer === 'LOCKED' -} - -function releaseActivationFence( - options: OrcadActivationLockOptions, - lockRoot: string -): Promise { - // Journal first: a release cut short must leave a lock without a journal, never the reverse. - const journal = joinRemotePath(options.host, lockRoot, ORCAD_ACTIVATION_TRANSACTION_FILENAME) - // Why no signal: a cancelled run must still be able to drop a fence it proved unnecessary. - return execOrcadRemote( - withoutAbortSignal(options), - `${removeRemoteFileCommand(options.host, journal)} && ${removeRemoteTreeCommand(options.host, lockRoot)}` - ).then(() => undefined) -} diff --git a/src/main/ssh/orcad-activation-record-store.ts b/src/main/ssh/orcad-activation-record-store.ts index a36dcc728e0..d60634ec525 100644 --- a/src/main/ssh/orcad-activation-record-store.ts +++ b/src/main/ssh/orcad-activation-record-store.ts @@ -6,50 +6,32 @@ * activated" would deploy over a live install and lose its rollback target. */ import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' import { RELAY_REMOTE_DIR } from './relay-protocol' import { ORCAD_ACTIVATION_FILENAME, emptyOrcadActivationRecord, parseOrcadActivationRecord, - serializeOrcadActivationRecord, - type OrcadActivationReadResult, type OrcadActivationRecord } from './orcad-activation-record' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' - -const ORCAD_ACTIVATION_RECORD_MAX_BYTES = 64 * 1024 - -type ActivationRecordTarget = { - conn: SshConnection - host: RemoteHostPlatform - remoteHome: string - signal?: AbortSignal -} export function orcadActivationPath(host: RemoteHostPlatform, remoteHome: string): string { return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_FILENAME) } -/** A failed read rejects; it never reads as absent, because absence would admit a fresh deploy. */ -async function readActivationRecordState( - options: ActivationRecordTarget -): Promise { - const read = await readBoundedOrcadRemoteRecord( - options, - orcadActivationPath(options.host, options.remoteHome), - ORCAD_ACTIVATION_RECORD_MAX_BYTES - ) - return read.state === 'absent' ? read : parseOrcadActivationRecord(read.raw) -} - -export async function readOrcadActivationRecord( - options: ActivationRecordTarget -): Promise { - const parsed = await readActivationRecordState(options) +export async function readOrcadActivationRecord(options: { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + signal?: AbortSignal +}): Promise { + const path = orcadActivationPath(options.host, options.remoteHome) + const raw = await execCommand(options.conn, `cat ${shellQuote(path)} 2>/dev/null || true`, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }).catch(() => '') + const parsed = parseOrcadActivationRecord(raw) if (parsed.state === 'ok') { return parsed.record } @@ -61,18 +43,6 @@ export async function readOrcadActivationRecord( return emptyOrcadActivationRecord() } -/** Refuses to replace a record this client cannot read, e.g. one a newer client wrote. */ -export async function writeOrcadActivationRecord( - options: ActivationRecordTarget, - record: OrcadActivationRecord -): Promise { - const existing = await readActivationRecordState(options) - if (existing.state === 'unreadable') { - throw new Error(`Refusing to overwrite this host's orcad activation record: ${existing.reason}`) - } - await writeAtomicOrcadRemoteRecord( - options, - orcadActivationPath(options.host, options.remoteHome), - serializeOrcadActivationRecord(record) - ) +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` } diff --git a/src/main/ssh/orcad-activation-record.ts b/src/main/ssh/orcad-activation-record.ts index 08f83a6970b..42b1e6703a8 100644 --- a/src/main/ssh/orcad-activation-record.ts +++ b/src/main/ssh/orcad-activation-record.ts @@ -154,19 +154,6 @@ export function withRolledBackVersion( } } -/** The record after decommissioning `active`: nothing serves; the stopped build stays pinned. */ -export function withDeactivatedVersion(record: OrcadActivationRecord): OrcadActivationRecord { - return { - schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, - active: null, - // Kept so GC leaves the slot whose daemon may still own terminals, and a redeploy can find it. - previous: record.active, - activatedAt: null, - // No version is active, so there is nothing a pre-activation snapshot could roll back to. - snapshot: null - } -} - /** * Version dirs GC must not remove, as directory names. * diff --git a/src/main/ssh/orcad-activation-recovery.ts b/src/main/ssh/orcad-activation-recovery.ts deleted file mode 100644 index 06cd63a2287..00000000000 --- a/src/main/ssh/orcad-activation-recovery.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Finishing or undoing an activation, rollback or decommission that a crash, a lost connection or an - * unverifiable failure left fenced. Either way the host ends serving exactly the slot its - * activation record names, or the fence stays for an operator. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import { - planOrcadTransactionRecovery, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { - orcadActivationFenceExists, - withStaleOrcadActivationRecoveryLock -} from './orcad-activation-lock' -import { RemoteInstallLockBusyError } from './ssh-relay-install-lock' -import { ensureOrcadSlotServing, resolveOrcadSlotIdentity } from './orcad-recovery-slot' -import { reconcileOrcadDecommission } from './orcad-decommission-recovery' -import { - recoverOrcadIncumbent, - type OrcadIncumbentRecoveryOptions -} from './orcad-incumbent-recovery' - -export type OrcadActivationRecoveryResult = - | { outcome: 'none' } - | { outcome: 'pending'; code: string; reason: string } - | { - outcome: 'recovered' - resolution: 'committed' | 'restored-incumbent' - activeVersion: string | null - readiness: ServeReadiness | null - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export type OrcadActivationRecoveryOptions = OrcadIncumbentRecoveryOptions - -export async function recoverInterruptedOrcadActivation( - options: OrcadActivationRecoveryOptions -): Promise { - try { - if ( - !(await readOrcadActivationTransaction(options)) && - !(await orcadActivationFenceExists(options)) - ) { - return { outcome: 'none' } - } - return await withStaleOrcadActivationRecoveryLock(options, async (lock) => { - const transaction = await readOrcadActivationTransaction(options) - if (!transaction) { - // A release cut short after removing the journal; dropping the lock finishes it. - return { outcome: 'none' } - } - const result = await reconcileOrcadTransaction(options, transaction) - if (result.outcome !== 'recovered') { - lock.retain() - } - return result - }) - } catch (error) { - if (error instanceof RemoteInstallLockBusyError) { - return { - outcome: 'pending', - code: 'orcad_recovery_transaction_still_fresh', - reason: - 'The activation fence is held by a run that may still be working. Retry after the ' + - 'install lock recovery window.' - } - } - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_unverifiable', - reason: - `The interrupted activation could not be reconciled safely: ${errorMessage(error)} ` + - 'The host remains fenced.' - } - } -} - -/** Runs under a held fence. Throws when a step is unverifiable. */ -export async function reconcileOrcadTransaction( - options: OrcadActivationRecoveryOptions, - transaction: OrcadActivationTransaction -): Promise { - const plan = planOrcadTransactionRecovery(transaction, await readOrcadActivationRecord(options)) - if (plan.action === 'refuse') { - return { outcome: 'refused', verdict: 'unverifiable', code: plan.code, reason: plan.reason } - } - if ( - plan.action === 'confirm-decommissioned' || - plan.action === 'resume-stop' || - plan.action === 'keep-serving' - ) { - return reconcileOrcadDecommission(options, plan) - } - if (plan.action === 'finish-commit') { - await writeOrcadActivationRecord(options, plan.record) - } - if (plan.action === 'stabilize-committed' || plan.action === 'finish-commit') { - const activeVersion = plan.action === 'finish-commit' ? plan.record.active : plan.activeVersion - if (!activeVersion) { - throw new Error('The committed activation record has no active version.') - } - const identity = await resolveOrcadSlotIdentity(options, activeVersion) - return { - outcome: 'recovered', - resolution: 'committed', - activeVersion, - readiness: await ensureOrcadSlotServing(options, identity) - } - } - const recovery = await recoverOrcadIncumbent(options, { - transactionStartedAt: transaction.startedAt, - launchedVersion: plan.launchedVersion, - incumbent: plan.activeVersion - ? await resolveOrcadSlotIdentity(options, plan.activeVersion) - : null, - restoreState: plan.restoreState - }) - return recovery.outcome === 'refused' - ? recovery - : { - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: plan.activeVersion, - readiness: recovery.readiness - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-activation-transaction-schema.ts b/src/main/ssh/orcad-activation-transaction-schema.ts deleted file mode 100644 index 14c776b3a66..00000000000 --- a/src/main/ssh/orcad-activation-transaction-schema.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { z } from 'zod' -import { OrcadManagedStopRequestSchema } from '../../shared/orcad-stop-request' -import { - ORCAD_INSTALL_MODEL, - remoteInstallDirName, - remoteInstallVersionDirRegex -} from './remote-install-model' - -export const ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION = 1 - -const RemoteVersionSchema = z - .string() - .refine( - (version) => - remoteInstallVersionDirRegex(ORCAD_INSTALL_MODEL).test( - remoteInstallDirName(ORCAD_INSTALL_MODEL, version) - ), - 'Expected a safe remote install version' - ) -const SafeSnapshotNameSchema = z - .string() - .min(1) - .max(255) - .regex(/^[A-Za-z0-9][A-Za-z0-9.+-]*$/u) -const SnapshotVerdictSchema = z.object({ - dirName: SafeSnapshotNameSchema, - state: z.enum(['pending', 'captured', 'empty']) -}) -const OrcadActivationTransactionCommonFields = { - schemaVersion: z.literal(ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION), - transactionId: z.uuid(), - startedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - recordBefore: z.unknown() -} - -// Why strict operations: an older client reading a newer operation must keep the fence. -export const OrcadActivationTransactionSchema = z - .discriminatedUnion('operation', [ - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('activate'), - phase: z.enum(['prepared', 'incumbent-stopped', 'snapshot-captured', 'candidate-ready']), - candidateVersion: RemoteVersionSchema, - recordAfter: z.unknown().nullable(), - snapshot: SnapshotVerdictSchema - }), - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('rollback'), - phase: z.enum([ - 'prepared', - 'incumbent-stopped', - 'rescue-captured', - 'rollback-state-restored', - 'target-ready' - ]), - incumbentVersion: RemoteVersionSchema, - targetVersion: RemoteVersionSchema, - recordAfter: z.unknown(), - rescue: SnapshotVerdictSchema - }), - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('decommission'), - phase: z.enum(['prepared', 'stop-dispatched', 'process-exited']), - activeVersion: RemoteVersionSchema, - recordAfter: z.unknown(), - /** The instance-bound stop request; durable before it can reach the host. */ - request: OrcadManagedStopRequestSchema.nullable() - }) - ]) - .superRefine((transaction, context) => { - if (transaction.operation === 'decommission') { - if ((transaction.phase === 'prepared') !== (transaction.request === null)) { - context.addIssue({ code: 'custom', message: 'Stop request is inconsistent with phase' }) - } - if (transaction.request && transaction.request.transactionId !== transaction.transactionId) { - context.addIssue({ code: 'custom', message: 'Stop request names another transaction' }) - } - return - } - const beforeVerdict = - transaction.phase === 'prepared' || transaction.phase === 'incumbent-stopped' - const verdict = transaction.operation === 'activate' ? transaction.snapshot : transaction.rescue - if (beforeVerdict && verdict.state !== 'pending') { - context.addIssue({ code: 'custom', message: 'Snapshot state advanced before its phase' }) - } - if (!beforeVerdict && verdict.state === 'pending') { - context.addIssue({ code: 'custom', message: 'Snapshot phase has no durable verdict' }) - } - if ( - transaction.operation === 'activate' && - (transaction.phase === 'candidate-ready') !== (transaction.recordAfter !== null) - ) { - context.addIssue({ code: 'custom', message: 'Committed record is inconsistent with phase' }) - } - }) diff --git a/src/main/ssh/orcad-activation-transaction-store.ts b/src/main/ssh/orcad-activation-transaction-store.ts deleted file mode 100644 index 1e1b92927e4..00000000000 --- a/src/main/ssh/orcad-activation-transaction-store.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - ORCAD_ACTIVATION_TRANSACTION_FILENAME, - parseOrcadActivationTransaction, - serializeOrcadActivationTransaction, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { orcadActivationTransactionRoot } from './orcad-activation-lock' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' -import type { OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const ORCAD_ACTIVATION_TRANSACTION_MAX_BYTES = 64 * 1024 - -type OrcadActivationTransactionStoreOptions = OrcadRemoteExecTarget & { remoteHome: string } - -export function orcadActivationTransactionPath( - host: RemoteHostPlatform, - remoteHome: string -): string { - return joinRemotePath( - host, - orcadActivationTransactionRoot(host, remoteHome), - ORCAD_ACTIVATION_TRANSACTION_FILENAME - ) -} - -/** `null` only on a verified absence; an unreadable journal keeps the host fenced. */ -export async function readOrcadActivationTransaction( - options: OrcadActivationTransactionStoreOptions -): Promise { - const read = await readBoundedOrcadRemoteRecord( - options, - orcadActivationTransactionPath(options.host, options.remoteHome), - ORCAD_ACTIVATION_TRANSACTION_MAX_BYTES - ) - const parsed = parseOrcadActivationTransaction(read.state === 'present' ? read.raw : null) - if (parsed.state === 'unreadable') { - throw new Error(`Cannot read this host's orcad activation transaction: ${parsed.reason}`) - } - return parsed.state === 'ok' ? parsed.transaction : null -} - -export function writeOrcadActivationTransaction( - options: OrcadActivationTransactionStoreOptions, - transaction: OrcadActivationTransaction -): Promise { - return writeAtomicOrcadRemoteRecord( - options, - orcadActivationTransactionPath(options.host, options.remoteHome), - serializeOrcadActivationTransaction(transaction) - ) -} diff --git a/src/main/ssh/orcad-activation-transaction-transitions.ts b/src/main/ssh/orcad-activation-transaction-transitions.ts deleted file mode 100644 index 95facd02daa..00000000000 --- a/src/main/ssh/orcad-activation-transaction-transitions.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { OrcadActivationRecord } from './orcad-activation-record' -import { ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION } from './orcad-activation-transaction-schema' -import type { - OrcadActivateTransaction, - OrcadRollbackTransaction -} from './orcad-activation-transaction' - -export function createOrcadActivationTransaction(options: { - transactionId: string - candidateVersion: string - recordBefore: OrcadActivationRecord - snapshotDirName: string - now: Date -}): OrcadActivateTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'activate', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - candidateVersion: options.candidateVersion, - recordBefore: options.recordBefore, - recordAfter: null, - snapshot: { dirName: options.snapshotDirName, state: 'pending' } - } -} - -export function withOrcadActivationIncumbentStopped( - transaction: OrcadActivateTransaction, - now: Date -): OrcadActivateTransaction { - return { ...transaction, phase: 'incumbent-stopped', updatedAt: now.toISOString() } -} - -export function withOrcadActivationSnapshot( - transaction: OrcadActivateTransaction, - state: 'captured' | 'empty', - now: Date -): OrcadActivateTransaction { - return { - ...transaction, - phase: 'snapshot-captured', - updatedAt: now.toISOString(), - snapshot: { dirName: transaction.snapshot.dirName, state } - } -} - -export function withOrcadActivationCandidateReady( - transaction: OrcadActivateTransaction, - recordAfter: OrcadActivationRecord, - now: Date -): OrcadActivateTransaction { - return { ...transaction, phase: 'candidate-ready', updatedAt: now.toISOString(), recordAfter } -} - -export function createOrcadRollbackTransaction(options: { - transactionId: string - incumbentVersion: string - targetVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - rescueDirName: string - now: Date -}): OrcadRollbackTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'rollback', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - incumbentVersion: options.incumbentVersion, - targetVersion: options.targetVersion, - recordBefore: options.recordBefore, - recordAfter: options.recordAfter, - rescue: { dirName: options.rescueDirName, state: 'pending' } - } -} - -export function withOrcadRollbackPhase( - transaction: OrcadRollbackTransaction, - phase: 'incumbent-stopped' | 'rollback-state-restored' | 'target-ready', - now: Date -): OrcadRollbackTransaction { - return { ...transaction, phase, updatedAt: now.toISOString() } -} - -export function withOrcadRollbackRescue( - transaction: OrcadRollbackTransaction, - state: 'captured' | 'empty', - now: Date -): OrcadRollbackTransaction { - return { - ...transaction, - phase: 'rescue-captured', - updatedAt: now.toISOString(), - rescue: { dirName: transaction.rescue.dirName, state } - } -} diff --git a/src/main/ssh/orcad-activation-transaction.test.ts b/src/main/ssh/orcad-activation-transaction.test.ts deleted file mode 100644 index 65fbb6227d0..00000000000 --- a/src/main/ssh/orcad-activation-transaction.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as InstallLock from './ssh-relay-install-lock' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn().mockResolvedValue('') -})) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn().mockResolvedValue(undefined) -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { - parseOrcadActivationTransaction, - planOrcadTransactionRecovery, - serializeOrcadActivationTransaction, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { - createOrcadActivationTransaction, - createOrcadRollbackTransaction, - withOrcadActivationCandidateReady, - withOrcadActivationIncumbentStopped, - withOrcadActivationSnapshot, - withOrcadRollbackPhase, - withOrcadRollbackRescue -} from './orcad-activation-transaction-transitions' -import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock, - withStaleOrcadActivationRecoveryLock -} from './orcad-activation-lock' -import { FakeOrcadHost, NEW, OLD } from './orcad-activation-host-test-harness' -import { withRolledBackVersion } from './orcad-activation-record' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' - -const T = new Date('2026-02-02T00:00:00.000Z') -const ID = '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f' -const before = FakeOrcadHost.newRecord() - -function activation(): ReturnType { - return createOrcadActivationTransaction({ - transactionId: ID, - candidateVersion: '0.3.0+cc01', - recordBefore: before, - snapshotDirName: 'pre-0.3.0+cc01-1', - now: T - }) -} - -function rollback(): ReturnType { - return createOrcadRollbackTransaction({ - transactionId: ID, - incumbentVersion: NEW, - targetVersion: OLD, - recordBefore: before, - recordAfter: withRolledBackVersion(before, T), - rescueDirName: 'rollback-rescue-0.2.0+bb01-1', - now: T - }) -} - -const roundTrip = (transaction: OrcadActivationTransaction): unknown => - parseOrcadActivationTransaction(serializeOrcadActivationTransaction(transaction)) - -describe('parseOrcadActivationTransaction', () => { - it('round-trips every phase of both operations', () => { - const stopped = withOrcadActivationIncumbentStopped(activation(), T) - const captured = withOrcadActivationSnapshot(stopped, 'captured', T) - const ready = withOrcadActivationCandidateReady( - captured, - { ...before, active: '0.3.0+cc01' }, - T - ) - const rescued = withOrcadRollbackRescue( - withOrcadRollbackPhase(rollback(), 'incumbent-stopped', T), - 'empty', - T - ) - for (const transaction of [activation(), stopped, captured, ready, rollback(), rescued]) { - expect(roundTrip(transaction)).toEqual({ state: 'ok', transaction }) - } - }) - - it.each([ - ['an unknown operation, so an older client keeps a newer fence', { operation: 'decommission' }], - ['a snapshot verdict before its phase', { snapshot: { dirName: 'pre-x', state: 'captured' } }], - ['a committed record before candidate-ready', { recordAfter: before }], - ['an unsafe version', { candidateVersion: '../../etc' }], - ['an unsafe snapshot name', { snapshot: { dirName: '../x', state: 'pending' } }] - ])('reads %s as unreadable', (_name, patch) => { - const raw = JSON.stringify({ ...activation(), ...patch }) - expect(parseOrcadActivationTransaction(raw)).toMatchObject({ state: 'unreadable' }) - }) - - it('rejects a rollback whose versions disagree with its records', () => { - const raw = JSON.stringify({ ...rollback(), targetVersion: '0.0.9+dd01' }) - expect(parseOrcadActivationTransaction(raw)).toMatchObject({ state: 'unreadable' }) - }) -}) - -describe('planOrcadTransactionRecovery', () => { - const after = { ...before, active: '0.3.0+cc01' } - const captured = withOrcadActivationSnapshot( - withOrcadActivationIncumbentStopped(activation(), T), - 'captured', - T - ) - - it.each([ - ['prepared', activation(), null, null], - ['incumbent-stopped', withOrcadActivationIncumbentStopped(activation(), T), null, null], - ['snapshot-captured', captured, '0.3.0+cc01', 'captured'] - ] as const)('undoes an activation interrupted at %s', (_phase, transaction, launched, state) => { - expect(planOrcadTransactionRecovery(transaction, before)).toMatchObject({ - action: 'undo', - launchedVersion: launched, - activeVersion: NEW, - restoreState: state === null ? null : { state } - }) - }) - - it('finishes a commit whose record write was lost, and stabilizes one that landed', () => { - const ready = withOrcadActivationCandidateReady(captured, after, T) - expect(planOrcadTransactionRecovery(ready, before)).toEqual({ - action: 'finish-commit', - record: after - }) - expect(planOrcadTransactionRecovery(ready, after)).toEqual({ - action: 'stabilize-committed', - activeVersion: '0.3.0+cc01' - }) - }) - - it('restores the rescue once a rollback may have replaced the state', () => { - const rescued = withOrcadRollbackRescue(rollback(), 'captured', T) - expect(planOrcadTransactionRecovery(rescued, before)).toMatchObject({ - action: 'undo', - launchedVersion: null, - activeVersion: NEW, - restoreState: { state: 'captured' } - }) - expect( - planOrcadTransactionRecovery( - withOrcadRollbackPhase(rescued, 'rollback-state-restored', T), - before - ) - ).toMatchObject({ launchedVersion: OLD }) - }) - - it('refuses when the record matches neither side', () => { - expect(planOrcadTransactionRecovery(activation(), { ...before, previous: null })).toMatchObject( - { - action: 'refuse', - code: 'orcad_recovery_activation_record_changed' - } - ) - }) -}) - -describe('activation fence', () => { - const target = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u' - } - const removals = (): string[] => - vi - .mocked(execCommand) - .mock.calls.map(([, command]) => command) - .filter((command) => command.includes('rm -')) - - it('never takes over a held fence by age, and removes the journal before the lock', async () => { - vi.clearAllMocks() - await withOrcadActivationLock(target, async () => undefined) - expect(vi.mocked(acquireInstallLock).mock.calls[0]?.[3]).toMatchObject({ - allowStaleTakeover: false, - relayGcClaim: false - }) - const [release] = removals() - expect(release?.indexOf('transaction.json')).toBeLessThan( - release?.indexOf("rm -rf '/home/u/.orca-remote/.orcad-activation-transaction'") ?? -1 - ) - }) - - it('keeps the fence after an unconfirmed termination, a retained error, or retain()', async () => { - vi.clearAllMocks() - const lost = Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) - await expect(withOrcadActivationLock(target, () => Promise.reject(lost))).rejects.toBe(lost) - await expect( - withOrcadActivationLock(target, async (lock) => { - lock.retainOnError() - throw new Error('mid-transaction') - }) - ).rejects.toThrow('mid-transaction') - await withOrcadActivationLock(target, async (lock) => lock.retain()) - expect(removals()).toEqual([]) - }) - - it('releases after a recovered failure even though the run throws', async () => { - vi.clearAllMocks() - await expect( - withOrcadActivationLock(target, async (lock) => { - lock.retainOnError() - lock.recovered() - throw new Error('snapshot failed; incumbent restarted') - }) - ).rejects.toThrow('incumbent restarted') - expect(removals()).toHaveLength(1) - }) - - it('lets recovery take over only stale fences, without waiting', async () => { - vi.clearAllMocks() - await withStaleOrcadActivationRecoveryLock(target, async () => undefined) - expect(vi.mocked(acquireInstallLock).mock.calls[0]?.[3]).toMatchObject({ - allowStaleTakeover: true, - waitTimeoutMs: 0 - }) - }) - - it.each([0, -1, 5 * 60_000 + 1, 1.5])('rejects readiness timeout %s', (timeout) => { - expect(() => resolveOrcadActivationReadinessTimeout(timeout, 1)).toThrow() - }) -}) diff --git a/src/main/ssh/orcad-activation-transaction.ts b/src/main/ssh/orcad-activation-transaction.ts deleted file mode 100644 index cc00c51f642..00000000000 --- a/src/main/ssh/orcad-activation-transaction.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * The host-side journal that makes an orcad activation, rollback or decommission crash-safe. - * - * Written before the first mutation and kept until the host is proven to serve exactly one - * slot again. The activation record stays the commit point: a journal whose `recordAfter` - * matches the record committed; one whose `recordBefore` matches did not, and recovery puts - * the pre-transaction slot and state back. Anything else keeps the fence for an operator. - */ -import { - parseOrcadActivationRecord, - serializeOrcadActivationRecord, - type OrcadActivationRecord -} from './orcad-activation-record' -import { - type ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - OrcadActivationTransactionSchema -} from './orcad-activation-transaction-schema' -import { - orcadDecommissionTransactionDefect, - planOrcadDecommissionRecovery, - type OrcadDecommissionRecoveryPlan, - type OrcadDecommissionTransaction -} from './orcad-decommission-transaction' - -export const ORCAD_ACTIVATION_TRANSACTION_FILENAME = 'transaction.json' -export const ORCAD_ACTIVATION_TRANSACTION_DIRNAME = '.orcad-activation-transaction' - -export type OrcadSnapshotVerdict = { dirName: string; state: 'pending' | 'captured' | 'empty' } - -export type OrcadActivateTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'activate' - phase: 'prepared' | 'incumbent-stopped' | 'snapshot-captured' | 'candidate-ready' - startedAt: string - updatedAt: string - candidateVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord | null - snapshot: OrcadSnapshotVerdict -} - -export type OrcadRollbackTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'rollback' - phase: - | 'prepared' - | 'incumbent-stopped' - | 'rescue-captured' - | 'rollback-state-restored' - | 'target-ready' - startedAt: string - updatedAt: string - incumbentVersion: string - targetVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - rescue: OrcadSnapshotVerdict -} - -export type OrcadActivationTransaction = - | OrcadActivateTransaction - | OrcadRollbackTransaction - | OrcadDecommissionTransaction - -export type OrcadActivationTransactionReadResult = - | { state: 'absent' } - | { state: 'ok'; transaction: OrcadActivationTransaction } - | { state: 'unreadable'; reason: string } - -/** What recovery must do; `undo` lists the state to put back once the new slot is quiescent. */ -export type OrcadTransactionRecoveryPlan = - | { action: 'stabilize-committed'; activeVersion: string | null } - /** The new slot passed its gate and the journal holds the record; only the write was lost. */ - | { action: 'finish-commit'; record: OrcadActivationRecord } - | { - action: 'undo' - /** The slot that may have run after the state changed hands. */ - launchedVersion: string | null - activeVersion: string | null - restoreState: OrcadSnapshotVerdict | null - } - | OrcadDecommissionRecoveryPlan - | { action: 'refuse'; code: string; reason: string } - -export function parseOrcadActivationTransaction( - raw: string | null -): OrcadActivationTransactionReadResult { - if (raw === null || raw.trim() === '') { - return { state: 'absent' } - } - let json: unknown - try { - json = JSON.parse(raw) - } catch (error) { - return unreadable(`transaction is not JSON: ${errorMessage(error)}`) - } - const parsed = OrcadActivationTransactionSchema.safeParse(json) - if (!parsed.success) { - const issue = parsed.error.issues[0] - const path = issue?.path.length ? issue.path.join('.') : 'transaction' - return unreadable(`${path} is invalid: ${issue?.message ?? 'unknown shape'}`) - } - const recordBefore = parseNestedRecord(parsed.data.recordBefore, 'recordBefore') - if (recordBefore.state === 'unreadable') { - return recordBefore - } - if (parsed.data.operation === 'decommission') { - const after = parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (after.state === 'unreadable') { - return after - } - const transaction = { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: after.record - } - const defect = orcadDecommissionTransactionDefect(transaction) - return defect ? unreadable(defect) : { state: 'ok', transaction } - } - if (parsed.data.operation === 'activate') { - const recordAfter = - parsed.data.recordAfter === null - ? null - : parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (recordAfter?.state === 'unreadable') { - return recordAfter - } - if (recordAfter && recordAfter.record.active !== parsed.data.candidateVersion) { - return unreadable('recordAfter does not activate candidateVersion') - } - return { - state: 'ok', - transaction: { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: recordAfter?.record ?? null - } - } - } - const recordAfter = parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (recordAfter.state === 'unreadable') { - return recordAfter - } - if ( - recordBefore.record.active !== parsed.data.incumbentVersion || - recordBefore.record.previous !== parsed.data.targetVersion - ) { - return unreadable('rollback versions do not match recordBefore') - } - if ( - recordAfter.record.active !== parsed.data.targetVersion || - recordAfter.record.previous !== null || - recordAfter.record.snapshot !== null - ) { - return unreadable('recordAfter is not a completed rollback record') - } - return { - state: 'ok', - transaction: { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: recordAfter.record - } - } -} - -export function serializeOrcadActivationTransaction( - transaction: OrcadActivationTransaction -): string { - return `${JSON.stringify(transaction, null, 2)}\n` -} - -export function planOrcadTransactionRecovery( - transaction: OrcadActivationTransaction, - currentRecord: OrcadActivationRecord -): OrcadTransactionRecoveryPlan { - if (transaction.operation === 'decommission') { - const committed = sameOrcadActivationRecord(currentRecord, transaction.recordAfter) - return committed || sameOrcadActivationRecord(currentRecord, transaction.recordBefore) - ? planOrcadDecommissionRecovery(transaction, committed) - : recordChangedRefusal(transaction) - } - if ( - transaction.recordAfter && - sameOrcadActivationRecord(currentRecord, transaction.recordAfter) - ) { - return { action: 'stabilize-committed', activeVersion: transaction.recordAfter.active } - } - if (!sameOrcadActivationRecord(currentRecord, transaction.recordBefore)) { - return recordChangedRefusal(transaction) - } - if (transaction.phase === 'candidate-ready' || transaction.phase === 'target-ready') { - return { action: 'finish-commit', record: transaction.recordAfter ?? neverRecord() } - } - if (transaction.operation === 'activate') { - // The candidate launches only after the snapshot verdict is durable. - const launched = transaction.phase === 'snapshot-captured' - return { - action: 'undo', - launchedVersion: launched ? transaction.candidateVersion : null, - activeVersion: transaction.recordBefore.active, - restoreState: launched ? transaction.snapshot : null - } - } - // The rescue is the incumbent's state; it only needs restoring once the target's replaced it. - const replaced = transaction.phase === 'rollback-state-restored' - const rescued = replaced || transaction.phase === 'rescue-captured' - return { - action: 'undo', - launchedVersion: replaced ? transaction.targetVersion : null, - activeVersion: transaction.incumbentVersion, - // A crash mid-restore leaves the phase at rescue-captured with the root half replaced. - restoreState: rescued ? transaction.rescue : null - } -} - -export function sameOrcadActivationRecord( - left: OrcadActivationRecord, - right: OrcadActivationRecord -): boolean { - return serializeOrcadActivationRecord(left) === serializeOrcadActivationRecord(right) -} - -function parseNestedRecord( - value: unknown, - field: string -): { state: 'ok'; record: OrcadActivationRecord } | { state: 'unreadable'; reason: string } { - const parsed = parseOrcadActivationRecord(JSON.stringify(value)) - return parsed.state === 'ok' - ? { state: 'ok', record: parsed.record } - : unreadable( - `${field} is invalid: ${parsed.state === 'absent' ? 'record is absent' : parsed.reason}` - ) -} - -function recordChangedRefusal( - transaction: OrcadActivationTransaction -): Extract { - return { - action: 'refuse', - code: 'orcad_recovery_activation_record_changed', - reason: - `The activation record matches neither side of the interrupted ${transaction.operation}. ` + - 'Preserving the activation fence for operator inspection.' - } -} - -function neverRecord(): never { - throw new Error('A committed phase must carry its record; the schema enforces this.') -} - -function unreadable(reason: string): { state: 'unreadable'; reason: string } { - return { state: 'unreadable', reason } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-active-readiness.ts b/src/main/ssh/orcad-active-readiness.ts deleted file mode 100644 index 5fe6c157f9b..00000000000 --- a/src/main/ssh/orcad-active-readiness.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** Proving that the orcad an activation record names is the one actually serving. */ -import { evaluateOrcadActivation, type OrcadActivationExpectation } from './orcad-activation-gate' -import { - orcadLivenessProbeCommand, - parseOrcadLiveness, - parseOrcadReadinessOutput, - readOrcadReadinessCommand, - type OrcadLaunchSpec, - type OrcadReadinessParse -} from './orcad-remote-launch' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - type OrcadRemoteExecTarget -} from './orcad-remote-runtime-control' -import type { ServeReadiness } from '../server/serve-readiness' - -/** `exited` is proven absence; `unverifiable` means the host could not say, which is not death. */ -export type OrcadReadinessFailureVerdict = 'exited' | 'unverifiable' | 'rejected' - -export class OrcadActiveReadinessError extends Error { - constructor( - readonly verdict: OrcadReadinessFailureVerdict, - message: string - ) { - super(message) - this.name = 'OrcadActiveReadinessError' - } -} - -function gatedReadiness( - parsed: OrcadReadinessParse, - expectation: OrcadActivationExpectation, - label: string -): ServeReadiness { - const readiness = parsed.state === 'ready' ? parsed.readiness : null - const verdict = evaluateOrcadActivation(readiness, expectation) - if (verdict.decision === 'reject') { - throw new OrcadActiveReadinessError( - 'rejected', - `${label} failed its readiness check: ${verdict.reason}` - ) - } - if (!readiness) { - throw new OrcadActiveReadinessError( - 'rejected', - `${label} passed activation without a readiness payload.` - ) - } - const coverage = orcadDaemonCoverageRefusal(readiness) - if (coverage) { - throw new OrcadActiveReadinessError('rejected', `${label} ${coverage}`) - } - return readiness -} - -/** - * A slot proves terminals only when its daemon's self-test spawned a PTY, or completed the - * handshake on a platform whose daemon never spawn-probes. A build that predates the coverage - * field keeps the identity gate alone, so an older slot is not stranded. - */ -export function orcadDaemonCoverageRefusal(readiness: ServeReadiness): string | null { - const health = readiness.health - const coverage = health?.terminalDaemon?.selfTest?.coverage - if (!health || coverage === undefined || coverage === 'pty-spawn') { - return null - } - if (coverage === 'handshake' && health.platform === 'win32') { - return null - } - return ( - `reported terminal-daemon coverage '${String(coverage)}', which does not prove a PTY can ` + - `be created on ${health.platform}.` - ) -} - -/** Checks a recorded-active slot without starting anything. */ -export async function probeActiveOrcadReadiness( - target: OrcadRemoteExecTarget & { remoteInstallDir: string }, - expectation: OrcadActivationExpectation -): Promise { - const liveness = parseOrcadLiveness( - await execOrcadRemote(target, orcadLivenessProbeCommand(target.host, target.remoteInstallDir)) - ) - if (liveness === 'DEAD') { - throw new OrcadActiveReadinessError( - 'exited', - `orcad ${expectation.fullVersion} is recorded active but its process has exited.` - ) - } - if (liveness !== 'LIVE') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${expectation.fullVersion} process state is unverifiable.` - ) - } - const parsed = parseOrcadReadinessOutput( - await execOrcadRemote(target, readOrcadReadinessCommand(target.host, target.remoteInstallDir)) - ) - return gatedReadiness(parsed, expectation, 'The active orcad') -} - -/** Starts a slot (recovery or rollback) and accepts it only if it proves the expected build. */ -export async function launchOrcadSlotAndAwaitReadiness( - target: OrcadRemoteExecTarget & { - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise - }, - spec: OrcadLaunchSpec, - expectation: OrcadActivationExpectation -): Promise { - const parsed = await launchOrcadAndAwaitReadiness(target, spec) - return gatedReadiness(parsed, expectation, `orcad ${spec.fullVersion}`) -} diff --git a/src/main/ssh/orcad-daemon-protocol-crossing.ts b/src/main/ssh/orcad-daemon-protocol-crossing.ts deleted file mode 100644 index 9a2506d0896..00000000000 --- a/src/main/ssh/orcad-daemon-protocol-crossing.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * D7: whether live terminals survive an orcad restart onto another build. - * - * The daemon outlives every orcad restart, so after the swap the incoming build must route - * sessions owned by a daemon that may speak another protocol. It can only when it speaks that - * protocol or lists it as previous — the same rule `config/scripts/daemon-protocol-facts.mjs` - * (`canAttach`) applies to release pairs in CI. - */ -import { - PREVIOUS_DAEMON_PROTOCOL_VERSIONS, - PROTOCOL_VERSION -} from '../daemon/daemon-protocol-version' -import type { OrcadTerminalCensus } from './orcad-update-plan' - -export type OrcadDaemonProtocolFacts = { - protocolVersion: number - previousProtocolVersions: readonly number[] -} - -/** This client's build, which is also the orcad bundle it deploys. */ -export const CURRENT_ORCAD_DAEMON_PROTOCOL: OrcadDaemonProtocolFacts = { - protocolVersion: PROTOCOL_VERSION, - previousProtocolVersions: PREVIOUS_DAEMON_PROTOCOL_VERSIONS -} - -export function orcadBuildCanAttachDaemon( - reader: OrcadDaemonProtocolFacts, - ownerProtocolVersion: number -): boolean { - return ( - reader.protocolVersion === ownerProtocolVersion || - reader.previousProtocolVersions.includes(ownerProtocolVersion) - ) -} - -export type OrcadLiveDaemonCrossing = - | 'no-live-terminals' - | 'attachable' - | 'strands-live-terminals' - | 'unverifiable' - -/** An unknown session count or daemon protocol is planned for as live and unattachable. */ -export function assessOrcadLiveDaemonCrossing( - census: OrcadTerminalCensus, - incoming: OrcadDaemonProtocolFacts -): OrcadLiveDaemonCrossing { - if (census.liveSessions === 0) { - return 'no-live-terminals' - } - if (census.daemonProtocolVersion === null) { - return 'unverifiable' - } - return orcadBuildCanAttachDaemon(incoming, census.daemonProtocolVersion) - ? 'attachable' - : 'strands-live-terminals' -} diff --git a/src/main/ssh/orcad-decommission-recovery.ts b/src/main/ssh/orcad-decommission-recovery.ts deleted file mode 100644 index 4c1fecbf698..00000000000 --- a/src/main/ssh/orcad-decommission-recovery.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Recovering an interrupted decommission from its journal entry; see orcad-decommission-transaction. */ -import { writeOrcadActivationRecord } from './orcad-activation-record-store' -import type { OrcadActivationRecoveryResult } from './orcad-activation-recovery' -import type { OrcadDecommissionRecoveryPlan } from './orcad-decommission-transaction' -import { settleOrcadDecommissionStop } from './orcad-decommission-stop' -import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { - ensureOrcadSlotServing, - orcadSlotDir, - resolveOrcadSlotIdentity, - type OrcadSlotOptions -} from './orcad-recovery-slot' - -export async function reconcileOrcadDecommission( - options: OrcadSlotOptions, - plan: OrcadDecommissionRecoveryPlan -): Promise { - if (plan.action === 'keep-serving') { - // Nothing was sent, so the recorded version must still be the one serving. - const identity = await resolveOrcadSlotIdentity(options, plan.version) - return { - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: plan.version, - readiness: await ensureOrcadSlotServing(options, identity) - } - } - if (plan.action === 'resume-stop') { - const settlement = await settleOrcadDecommissionStop(options, plan.request) - if (settlement.state === 'unsettled') { - return { - outcome: 'refused', - verdict: settlement.verdict, - code: 'orcad_recovery_decommission_unsettled', - reason: settlement.reason - } - } - if (settlement.state === 'withdrawn') { - return reconcileOrcadDecommission(options, { - action: 'keep-serving', - version: plan.request.version - }) - } - return reconcileOrcadDecommission(options, { - action: 'confirm-decommissioned', - version: plan.request.version, - record: plan.record - }) - } - // Only proven exit commits; a slot that is live or unanswering keeps the fence. - const liveness = parseOrcadLiveness( - await execOrcadRemote( - options, - orcadLivenessProbeCommand(options.host, orcadSlotDir(options, plan.version)) - ) - ) - if (liveness !== 'DEAD') { - return { - outcome: 'refused', - verdict: liveness === 'LIVE' ? 'live' : 'unverifiable', - code: 'orcad_recovery_decommissioned_slot_not_exited', - reason: `orcad ${plan.version} is recorded as stopped but is ${liveness.toLowerCase()}.` - } - } - if (plan.record) { - await writeOrcadActivationRecord(options, plan.record) - } - return { outcome: 'recovered', resolution: 'committed', activeVersion: null, readiness: null } -} diff --git a/src/main/ssh/orcad-decommission-stop.ts b/src/main/ssh/orcad-decommission-stop.ts deleted file mode 100644 index 42e2f0fa654..00000000000 --- a/src/main/ssh/orcad-decommission-stop.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Settling a dispatched decommission stop: proven exit, a clean cancellation, or a fence. - * - * Used by the decommission and by its crash recovery, so both read the host the same way. A - * lost answer is `unverifiable`, never exit; cancelling is how a stop that did not finish is - * withdrawn, and only an orcad-confirmed cancellation lets the fence go. - */ -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { - cancelRemoteOrcadManagedStop, - completeRemoteOrcadManagedStop -} from './orcad-managed-remote-stop' -import type { OrcadSlotOptions } from './orcad-recovery-slot' -import type { - OrcadDaemonRetirementVerdict, - OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -export type OrcadDecommissionStopSettlement = - | { state: 'exited'; retirement: OrcadDaemonRetirementVerdict } - /** orcad never acted on the request and keeps serving; the verdict says why it was withdrawn. */ - | { state: 'withdrawn'; verdict: 'live' | 'unverifiable'; reason: string } - /** orcad began stopping, or the host could not say: the fence must stay. */ - | { state: 'unsettled'; verdict: 'live' | 'unverifiable'; reason: string } - -export async function settleOrcadDecommissionStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - let verdict: 'live' | 'unverifiable' - let reason: string - try { - const completion = await completeRemoteOrcadManagedStop(options, request) - if (completion.verdict === 'exited') { - // A completion without a recorded outcome proves exit but not retirement. - return { state: 'exited', retirement: completion.retirement ?? 'unverifiable' } - } - verdict = completion.verdict - reason = `orcad ${request.version} did not exit (${completion.verdict}).` - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - verdict = 'unverifiable' - reason = `The host gave no verifiable stop verdict: ${errorMessage(error)}` - } - try { - const cancellation = await cancelRemoteOrcadManagedStop(options, request) - if (cancellation.outcome === 'canceled') { - return { - state: 'withdrawn', - verdict, - reason: `${reason} The stop request was withdrawn; orcad keeps serving.` - } - } - return { - state: 'unsettled', - verdict: 'live', - reason: `${reason} orcad already acted on the request and is still stopping.` - } - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { - state: 'unsettled', - verdict: 'unverifiable', - reason: `${reason} Withdrawing the request gave no verifiable answer: ${errorMessage(error)}` - } - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-decommission-transaction.test.ts b/src/main/ssh/orcad-decommission-transaction.test.ts deleted file mode 100644 index cb49f77a0d5..00000000000 --- a/src/main/ssh/orcad-decommission-transaction.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - parseOrcadActivationTransaction, - planOrcadTransactionRecovery, - serializeOrcadActivationTransaction -} from './orcad-activation-transaction' -import { - createOrcadDecommissionTransaction, - withOrcadDecommissionProcessExited, - withOrcadDecommissionStopDispatched -} from './orcad-decommission-transaction' -import { FakeOrcadHost, NEW } from './orcad-activation-host-test-harness' -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' - -const T = new Date('2026-02-02T00:00:00.000Z') -const ID = '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f' -const before = { ...FakeOrcadHost.newRecord(), active: NEW } -const prepared = createOrcadDecommissionTransaction({ - transactionId: ID, - recordBefore: before, - now: T -}) -const request: OrcadManagedStopRequest = { - schemaVersion: 1, - transactionId: ID, - version: NEW, - runtimeId: 'runtime-1', - instance: { pid: 42, startedAtMs: 5, nonce: 'nonce', lockPath: '/home/u/.orca/orcad.lock' }, - retireIdleDaemon: true -} -const dispatched = withOrcadDecommissionStopDispatched(prepared, request, T) -const exited = withOrcadDecommissionProcessExited(dispatched, T) - -describe('the decommission journal entry', () => { - it('deactivates the active version and keeps it as previous', () => { - expect(prepared.recordAfter).toMatchObject({ active: null, previous: NEW, snapshot: null }) - }) - - it('round-trips every phase', () => { - for (const transaction of [prepared, dispatched, exited]) { - expect( - parseOrcadActivationTransaction(serializeOrcadActivationTransaction(transaction)) - ).toEqual({ state: 'ok', transaction }) - } - }) - - it.each([ - ['a dispatched phase without its request', { ...dispatched, request: null }], - ['a request before dispatch', { ...prepared, request }], - [ - 'a request for another transaction', - { - ...dispatched, - request: { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - } - ], - [ - 'a request for another version', - { ...dispatched, request: { ...request, version: '0.9.0+ff01' } } - ], - ['a recordAfter that still serves', { ...prepared, recordAfter: before }] - ])('reads %s as unreadable, keeping the fence', (_name, transaction) => { - expect(parseOrcadActivationTransaction(JSON.stringify(transaction))).toMatchObject({ - state: 'unreadable' - }) - }) - - it('plans recovery from what the host is known to have done', () => { - const after = prepared.recordAfter - expect(planOrcadTransactionRecovery(prepared, before)).toEqual({ - action: 'keep-serving', - version: NEW - }) - expect(planOrcadTransactionRecovery(dispatched, before)).toEqual({ - action: 'resume-stop', - request, - record: after - }) - expect(planOrcadTransactionRecovery(exited, before)).toEqual({ - action: 'confirm-decommissioned', - version: NEW, - record: after - }) - expect(planOrcadTransactionRecovery(dispatched, after)).toEqual({ - action: 'confirm-decommissioned', - version: NEW, - record: null - }) - expect(planOrcadTransactionRecovery(dispatched, { ...after, previous: null })).toMatchObject({ - action: 'refuse' - }) - }) -}) diff --git a/src/main/ssh/orcad-decommission-transaction.ts b/src/main/ssh/orcad-decommission-transaction.ts deleted file mode 100644 index 4852cb965fe..00000000000 --- a/src/main/ssh/orcad-decommission-transaction.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * The decommission entry of the activation journal: stop the active orcad with an - * instance-bound request and record that nothing serves, or put it back. - * - * Phases: `prepared` (nothing sent), `stop-dispatched` (the request may have reached the - * host), `process-exited` (exit proven by a completed-stop receipt). Only the last may commit. - */ -import { - serializeOrcadActivationRecord, - withDeactivatedVersion, - type OrcadActivationRecord -} from './orcad-activation-record' -import { ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION } from './orcad-activation-transaction-schema' -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' - -export type OrcadDecommissionTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'decommission' - phase: 'prepared' | 'stop-dispatched' | 'process-exited' - startedAt: string - updatedAt: string - activeVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - request: OrcadManagedStopRequest | null -} - -export type OrcadDecommissionRecoveryPlan = - /** Exit was proven; write the deactivated record (if it is not there yet) and confirm. */ - | { action: 'confirm-decommissioned'; version: string; record: OrcadActivationRecord | null } - /** The request may have reached orcad; its completion or cancellation decides. */ - | { action: 'resume-stop'; request: OrcadManagedStopRequest; record: OrcadActivationRecord } - /** Nothing was sent; the active version must still be serving. */ - | { action: 'keep-serving'; version: string } - -export function createOrcadDecommissionTransaction(options: { - transactionId: string - recordBefore: OrcadActivationRecord & { active: string } - now: Date -}): OrcadDecommissionTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'decommission', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - activeVersion: options.recordBefore.active, - recordBefore: options.recordBefore, - recordAfter: withDeactivatedVersion(options.recordBefore), - request: null - } -} - -export function withOrcadDecommissionStopDispatched( - transaction: OrcadDecommissionTransaction, - request: OrcadManagedStopRequest, - now: Date -): OrcadDecommissionTransaction { - return { ...transaction, phase: 'stop-dispatched', updatedAt: now.toISOString(), request } -} - -export function withOrcadDecommissionProcessExited( - transaction: OrcadDecommissionTransaction, - now: Date -): OrcadDecommissionTransaction { - return { ...transaction, phase: 'process-exited', updatedAt: now.toISOString() } -} - -/** A reason when the parsed journal is not a coherent decommission; otherwise `null`. */ -export function orcadDecommissionTransactionDefect( - transaction: OrcadDecommissionTransaction -): string | null { - if (transaction.recordBefore.active !== transaction.activeVersion) { - return 'decommission version does not match recordBefore' - } - if ( - serializeOrcadActivationRecord(transaction.recordAfter) !== - serializeOrcadActivationRecord(withDeactivatedVersion(transaction.recordBefore)) - ) { - return 'recordAfter is not the deactivated recordBefore' - } - if (transaction.request && transaction.request.version !== transaction.activeVersion) { - return 'stop request names another version' - } - return null -} - -/** Called once the current record matched one side of the transaction. */ -export function planOrcadDecommissionRecovery( - transaction: OrcadDecommissionTransaction, - committed: boolean -): OrcadDecommissionRecoveryPlan { - if (committed) { - return { action: 'confirm-decommissioned', version: transaction.activeVersion, record: null } - } - if (transaction.phase === 'process-exited') { - return { - action: 'confirm-decommissioned', - version: transaction.activeVersion, - record: transaction.recordAfter - } - } - if (transaction.phase === 'stop-dispatched' && transaction.request) { - return { - action: 'resume-stop', - request: transaction.request, - record: transaction.recordAfter - } - } - return { action: 'keep-serving', version: transaction.activeVersion } -} diff --git a/src/main/ssh/orcad-gc-transaction-pins.ts b/src/main/ssh/orcad-gc-transaction-pins.ts deleted file mode 100644 index 4c90e6be434..00000000000 --- a/src/main/ssh/orcad-gc-transaction-pins.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * What an in-flight activation, rollback or decommission still needs from GC. - * - * The journal names every slot the transaction may restart or settle; GC must keep them all. - * A held fence without a journal, or a journal this client cannot read, means a transaction - * this client cannot see into, so GC keeps everything rather than guess. - */ -import { remoteInstallDirName, ORCAD_INSTALL_MODEL } from './remote-install-model' -import type { OrcadActivationTransaction } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { orcadActivationFenceExists } from './orcad-activation-lock' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import type { OrcadRemoteExecTarget } from './orcad-remote-runtime-control' - -export type OrcadGcTransactionPins = { state: 'pinned'; dirNames: string[] } | { state: 'keep-all' } - -function transactionVersions(transaction: OrcadActivationTransaction): (string | null)[] { - const records = [transaction.recordBefore, transaction.recordAfter] - const fromRecords = records.flatMap((record) => (record ? [record.active, record.previous] : [])) - if (transaction.operation === 'activate') { - return [...fromRecords, transaction.candidateVersion] - } - // Any other operation names its slots through its records. - return transaction.operation === 'rollback' - ? [...fromRecords, transaction.incumbentVersion, transaction.targetVersion] - : fromRecords -} - -export async function readOrcadGcTransactionPins( - target: OrcadRemoteExecTarget & { remoteHome: string } -): Promise { - try { - const transaction = await readOrcadActivationTransaction(target) - if (!transaction) { - // A fence without a journal is a transaction starting or a release cut short. - return (await orcadActivationFenceExists(target)) - ? { state: 'keep-all' } - : { state: 'pinned', dirNames: [] } - } - const versions = transactionVersions(transaction).filter( - (version): version is string => typeof version === 'string' && version.length > 0 - ) - return { - state: 'pinned', - dirNames: [...new Set(versions)].map((version) => - remoteInstallDirName(ORCAD_INSTALL_MODEL, version) - ) - } - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { state: 'keep-all' } - } -} diff --git a/src/main/ssh/orcad-incumbent-recovery.ts b/src/main/ssh/orcad-incumbent-recovery.ts deleted file mode 100644 index 5e68778c1a3..00000000000 --- a/src/main/ssh/orcad-incumbent-recovery.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Putting a host back to the slot and state it had before an activation or rollback. - * - * Shared by the in-flight failure paths and by crash recovery, so both apply one rule: state - * a launched slot may have changed is replaced only after that slot is proven exited, and only - * when the change provably carries no terminals — the slot exposed RPC, so a pre-launch census - * cannot vouch for it. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { ORCAD_STATE_SNAPSHOT_DIR } from './orcad-activation-record' -import type { OrcadSnapshotVerdict } from './orcad-activation-transaction' -import { - clearOrcadStateSnapshotMembersCommand, - compareOrcadStateSnapshotCommand, - orcadSnapshotIsUnchanged, - parseOrcadSnapshotRestore, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { - ensureOrcadSlotServing, - launchOrcadSlot, - quiesceInterruptedOrcadSlot, - type OrcadSlotIdentity, - type OrcadSlotOptions -} from './orcad-recovery-slot' -import { joinRemotePath } from './ssh-remote-platform' - -export type OrcadIncumbentRecoveryOptions = OrcadSlotOptions & { - /** - * Terminals started on the host since `since`, from a census taken now. Absent when no fresh - * census is available; `null` when the daemon did not answer. Both keep changed state. - */ - terminalsStartedSince?: (since: string) => Promise -} - -export type OrcadIncumbentRecovery = - | { outcome: 'restored'; readiness: ServeReadiness | null } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export function orcadSnapshotPath(options: OrcadSlotOptions, dirName: string): string { - return joinRemotePath( - options.host, - options.remoteHome, - RELAY_REMOTE_DIR, - ORCAD_STATE_SNAPSHOT_DIR, - dirName - ) -} - -/** Throws when a step cannot be verified; the caller keeps the fence. */ -export async function recoverOrcadIncumbent( - options: OrcadIncumbentRecoveryOptions, - input: { - transactionStartedAt: string - launchedVersion: string | null - incumbent: OrcadSlotIdentity | null - restoreState: OrcadSnapshotVerdict | null - /** This run itself proved both slots exited, so no fresh liveness probe is needed. */ - slotsProvenExited?: boolean - } -): Promise { - const quiescence = - input.slotsProvenExited || !input.restoreState - ? 'exited' - : await quiesceInterruptedOrcadSlot(options, input.launchedVersion, input.incumbent) - // With no incumbent there is no older reader to protect; the record names nothing to serve. - if (quiescence === 'exited' && input.restoreState && input.incumbent) { - const decision = input.launchedVersion - ? await decideChangedStateRestore(options, input.transactionStartedAt, input.restoreState) - : 'restore' - if (decision !== 'restore' && decision !== 'unchanged') { - return decision - } - if (decision === 'restore') { - await restoreState(options, input.restoreState) - } - } - if (!input.incumbent) { - return { outcome: 'restored', readiness: null } - } - return { - outcome: 'restored', - readiness: input.slotsProvenExited - ? await launchOrcadSlot(options, input.incumbent) - : await ensureOrcadSlotServing(options, input.incumbent) - } -} - -async function decideChangedStateRestore( - options: OrcadIncumbentRecoveryOptions, - since: string, - state: OrcadSnapshotVerdict -): Promise<'unchanged' | 'restore' | Extract> { - if (state.state === 'captured') { - const snapshotDir = orcadSnapshotPath(options, state.dirName) - // Read-only, so a lost answer is just "changed". - const comparison = await execOrcadRemote( - options, - compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) - ).catch(() => '') - if (orcadSnapshotIsUnchanged(comparison)) { - return 'unchanged' - } - } - const started = options.terminalsStartedSince - ? await options.terminalsStartedSince(since) - : undefined - if (started === 0) { - return 'restore' - } - const retained = - state.state === 'captured' ? ` at ${orcadSnapshotPath(options, state.dirName)}` : '' - return started === undefined || started === null - ? { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_census_required', - reason: - 'The launched build is stopped, but profile state changed or could not be verified, ' + - 'so the previous build was not restarted against it. Current state and daemon ' + - 'terminals are preserved; recovery requires a fresh host terminal census before ' + - `restoring the prelaunch snapshot${retained}.` - } - : { - outcome: 'refused', - verdict: 'live', - code: 'orcad_recovery_orphans_live_terminals', - reason: - `${started} terminal${started === 1 ? '' : 's'} started after the interrupted ` + - 'change, and the prelaunch snapshot does not describe them. Restoring it would ' + - `orphan running work; state is preserved${retained}.` - } -} - -async function restoreState(options: OrcadSlotOptions, state: OrcadSnapshotVerdict): Promise { - if (state.state === 'pending') { - throw new Error('The interrupted transaction has no durable snapshot verdict.') - } - const command = - state.state === 'captured' - ? restoreOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, state.dirName) - ) - : clearOrcadStateSnapshotMembersCommand(options.host, options.userDataDir) - const restored = parseOrcadSnapshotRestore(await execOrcadRemote(options, command)) - if (restored !== 'restored') { - throw new Error(`The prelaunch state could not be restored (${restored}).`) - } -} diff --git a/src/main/ssh/orcad-initial-activation-admission.ts b/src/main/ssh/orcad-initial-activation-admission.ts deleted file mode 100644 index cfa85da4dab..00000000000 --- a/src/main/ssh/orcad-initial-activation-admission.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Before the first managed activation, prove no unmanaged Orca runtime owns the data root. - * - * With no active record there is no incumbent to stop, but a hand-started orcad or headless - * Orca may still hold the shared root. Its owner records name a PID; a live, unreadable or - * unexpected record defers rather than starting a second owner beside it. - */ -import { ORCAD_LOCK_FILE_NAME } from '../orcad/orcad-instance-lock' -import { PRIMARY_RUNTIME_METADATA_FILE } from '../../shared/runtime-bootstrap' -import { shellEscape } from './ssh-connection-utils' -import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const OWNER_RECORD_MAX_BYTES = 64 * 1024 - -export type OrcadInitialActivationAdmission = - | { decision: 'proceed' } - | { decision: 'defer'; code: string; reason: string } - -/** Runs on the candidate slot's own runtime, so the probe needs no host Node. */ -export function initialOrcadActivationAdmissionCommand( - host: RemoteHostPlatform, - userDataDir: string, - remoteInstallDir: string, - legacyNodePath: string -): string { - const owners = [ORCAD_LOCK_FILE_NAME, PRIMARY_RUNTIME_METADATA_FILE].map((name) => ({ - name, - path: joinRemotePath(host, userDataDir, name) - })) - const script = [ - 'const fs=require("node:fs");', - `const limit=${OWNER_RECORD_MAX_BYTES};`, - 'const owners=JSON.parse(process.argv[1]??"[]");', - 'const invalid=(owner)=>`UNVERIFIABLE ${owner.name}`;', - 'function probe(owner){let fd;', - 'try{const noFollow=fs.constants.O_NOFOLLOW;', - 'if(typeof noFollow!=="number")return invalid(owner);', - 'fd=fs.openSync(owner.path,fs.constants.O_RDONLY|noFollow);', - 'const before=fs.fstatSync(fd);', - 'if(!before.isFile()||before.size>limit)return invalid(owner);', - 'const buffer=Buffer.alloc(limit+1);let bytes=0;', - 'while(byteslimit||bytes!==after.size||before.size!==after.size||before.mtimeMs!==after.mtimeMs)', - 'return invalid(owner);', - 'const record=JSON.parse(buffer.subarray(0,bytes).toString("utf8"));', - 'const pid=record?.pid;', - 'if(!Number.isSafeInteger(pid)||pid<=0)return invalid(owner);', - 'try{process.kill(pid,0);return `LIVE ${owner.name} ${pid}`;}', - 'catch(error){if(error?.code==="ESRCH")return null;', - 'if(error?.code==="EPERM")return `LIVE ${owner.name} ${pid}`;', - 'return invalid(owner);}}', - 'catch(error){return error?.code==="ENOENT"?null:invalid(owner);}', - 'finally{if(fd!==undefined){try{fs.closeSync(fd);}catch{}}}}', - 'for(const owner of owners){const result=probe(owner);', - 'if(result){console.log(result);process.exit(0);}}console.log("CLEAR");' - ].join('') - // The subshell turns the selector's `exit 78` into silence, which parses as unverifiable. - return ( - `(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, legacyNodePath)}; ` + - `"$orcad_runtime" -e ${shellEscape(script)} ${shellEscape(JSON.stringify(owners))})` - ) -} - -export function parseInitialOrcadActivationAdmission( - output: string -): OrcadInitialActivationAdmission { - const result = output.trim().split(/\r?\n/u).pop()?.trim() ?? '' - if (result === 'CLEAR') { - return { decision: 'proceed' } - } - const live = /^LIVE ([^ ]+) ([1-9][0-9]*)$/u.exec(result) - if (live) { - return { - decision: 'defer', - code: 'orcad_initial_runtime_live', - reason: - `An unmanaged runtime owner is still live according to ${live[1]} (pid ${live[2]}). ` + - 'Stop that Orca runtime before converting this data root to managed orcad.' - } - } - const record = /^UNVERIFIABLE ([^ ]+)$/u.exec(result)?.[1] ?? 'owner record' - return { - decision: 'defer', - code: 'orcad_initial_runtime_unverifiable', - reason: - `The host could not safely interpret ${record}, so it cannot prove the shared data root ` + - 'is quiescent. Preserve the file, verify its owner on the host, and retry after the owner exits.' - } -} diff --git a/src/main/ssh/orcad-installed-activation.ts b/src/main/ssh/orcad-installed-activation.ts deleted file mode 100644 index d3960508052..00000000000 --- a/src/main/ssh/orcad-installed-activation.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * The locked half of a deploy: stop, snapshot, start and commit, journaled at every step. - * - * The journal is durable before the first mutation, so a crash at any point leaves enough on - * the host for `recoverInterruptedOrcadActivation` to finish or undo it. A run that ends with - * the host provably back on one slot drops the fence; one that cannot prove it keeps it. - */ -import { randomUUID } from 'node:crypto' -import type { OrcadDeployOptions, OrcadDeployResult } from './orcad-remote-deploy' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { withActivatedVersion, type OrcadStateSnapshot } from './orcad-activation-record' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { evaluateOrcadActivation } from './orcad-activation-gate' -import { planOrcadUpdate } from './orcad-update-plan' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from './orcad-daemon-protocol-crossing' -import { ORCAD_LOG_FILENAME, type OrcadReadinessParse } from './orcad-remote-launch' -import { - captureOrcadStateSnapshotCommand, - orcadSnapshotDirName, - parseOrcadSnapshotCapture -} from './orcad-state-snapshot' -import { orcadStopFreedTheHost } from './orcad-remote-process-control' -import { joinRemotePath } from './ssh-remote-platform' -import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' -import { preflightInstalledOrcad } from './orcad-remote-preflight' -import type { OrcadActivationLockControl } from './orcad-activation-lock' -import type { OrcadActivateTransaction } from './orcad-activation-transaction' -import { - createOrcadActivationTransaction, - withOrcadActivationCandidateReady, - withOrcadActivationIncumbentStopped, - withOrcadActivationSnapshot -} from './orcad-activation-transaction-transitions' -import { - readOrcadActivationTransaction, - writeOrcadActivationTransaction -} from './orcad-activation-transaction-store' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - withoutAbortSignal -} from './orcad-remote-runtime-control' -import { - initialOrcadActivationAdmissionCommand, - parseInitialOrcadActivationAdmission -} from './orcad-initial-activation-admission' -import { - launchOrcadSlot, - orcadSlotDir, - resolveOrcadSlotIdentity, - stopOrcadSlot, - ORCAD_SLOT_STOP_WAIT_SECONDS, - type OrcadSlotIdentity -} from './orcad-recovery-slot' -import { orcadSnapshotPath, recoverOrcadIncumbent } from './orcad-incumbent-recovery' - -type Outcome = Extract - -export async function activateInstalledOrcad( - options: OrcadDeployOptions & { localOrcadDir: string }, - fullVersion: string, - remoteDir: string, - lock: OrcadActivationLockControl -): Promise { - const now = options.now ?? ((): Date => new Date()) - const notActivated = (code: string, reason: string): Outcome => ({ - outcome: 'installed-not-activated', - fullVersion, - code, - reason - }) - if (await readOrcadActivationTransaction(options)) { - // Holding the lock proves its writer is gone, but undoing its work needs a fresh census. - lock.retain() - return notActivated( - 'orcad_activation_recovery_required', - 'An earlier activation on this host was interrupted. Recover it before deploying again.' - ) - } - const record = await readOrcadActivationRecord(options) - const plan = planOrcadUpdate({ - record, - candidateVersion: fullVersion, - census: options.census, - candidateDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL, - ...(options.force !== undefined ? { force: options.force } : {}) - }) - if (plan.action === 'noop') { - return { outcome: 'already-active', fullVersion } - } - if (plan.action === 'defer') { - return notActivated(plan.code, plan.reason) - } - - try { - await preflightInstalledOrcad({ ...options, remoteInstallDir: remoteDir, fullVersion }) - } catch (error) { - options.signal?.throwIfAborted() - return notActivated( - 'orcad_candidate_preflight_failed', - `Candidate profile preflight failed; the incumbent was not stopped: ${errorMessage(error)}` - ) - } - - let incumbent: OrcadSlotIdentity | null = null - if (record.active) { - try { - incumbent = await resolveOrcadSlotIdentity(options, record.active) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return notActivated( - 'orcad_incumbent_identity_unverifiable', - `The active orcad ${record.active} build identity could not be verified before ` + - `stopping it: ${errorMessage(error)} Nothing was stopped.` - ) - } - } else { - const admission = parseInitialOrcadActivationAdmission( - await execOrcadRemote( - options, - initialOrcadActivationAdmissionCommand( - options.host, - options.userDataDir, - remoteDir, - options.nodePath - ) - ).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) - ) - if (admission.decision === 'defer') { - return notActivated(admission.code, admission.reason) - } - } - - const startedAt = now() - let transaction: OrcadActivateTransaction = createOrcadActivationTransaction({ - transactionId: randomUUID(), - candidateVersion: fullVersion, - recordBefore: record, - snapshotDirName: orcadSnapshotDirName(fullVersion, startedAt.getTime()), - now: startedAt - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - - if (incumbent) { - const stopped = await stopOrcadSlot(options, incumbent.remoteDir, false) - if (!orcadStopFreedTheHost(stopped)) { - // Only a delivered SIGTERM can still change the host; otherwise nothing happened. - if (stopped === 'still-running') { - lock.retain() - } - return notActivated( - 'orcad_outgoing_stop_incomplete', - `Could not verify that orcad ${incumbent.version} exited within ` + - `${ORCAD_SLOT_STOP_WAIT_SECONDS}s (${stopped}). No snapshot was taken and the ` + - 'candidate was not started. Orca requires matching runtime readiness before ' + - 'signaling an incumbent and confirmed exit before snapshotting.' - ) - } - } - transaction = withOrcadActivationIncumbentStopped(transaction, now()) - await writeOrcadActivationTransaction(options, transaction) - - // A live SQLite WAL is not a backup boundary, so the snapshot waits for confirmed exit. - const snapshotDir = orcadSnapshotPath(options, transaction.snapshot.dirName) - const capture = parseOrcadSnapshotCapture( - await execOrcadRemote( - options, - captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) - ).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return 'FAILED' - }) - ) - if (capture === 'failed') { - const restarted = incumbent - ? ` The incumbent was stopped before snapshotting; ${await restartAfterSnapshotFailure(options, incumbent, lock)}` - : '' - throw new Error( - `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + - 'persisted state carries no schema version, so without a snapshot a rollback has no ' + - `way back. Refusing to activate.${restarted}` - ) - } - const snapshot: OrcadStateSnapshot | null = - capture === 'captured' - ? { - dirName: transaction.snapshot.dirName, - takenBeforeVersion: fullVersion, - readableByVersion: record.active, - takenAt: startedAt.toISOString() - } - : null - transaction = withOrcadActivationSnapshot(transaction, capture, now()) - await writeOrcadActivationTransaction(options, transaction) - - let parsed: OrcadReadinessParse | null = null - let launchError: unknown - try { - parsed = await launchOrcadAndAwaitReadiness(options, { - remoteInstallDir: remoteDir, - nodePath: options.nodePath, - fullVersion, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - launchError = error - } - const verdict = evaluateOrcadActivation(parsed?.state === 'ready' ? parsed.readiness : null, { - buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), - fullVersion - }) - if (verdict.decision === 'reject') { - const [code, reason] = - launchError === undefined - ? [verdict.code, verdict.reason] - : [ - 'orcad_candidate_launch_failed', - `The candidate failed while starting: ${errorMessage(launchError)}` - ] - const restored = await restoreAfterRejectedCandidate(options, transaction, incumbent, lock) - return notActivated( - code, - `${reason} Candidate stderr is at ` + - `${joinRemotePath(options.host, remoteDir, ORCAD_LOG_FILENAME)}. ${restored}` - ) - } - - const recordAfter = withActivatedVersion(record, fullVersion, snapshot, now()) - transaction = withOrcadActivationCandidateReady(transaction, recordAfter, now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, recordAfter) - return { outcome: 'installed-and-activated', fullVersion, verdict } -} - -async function restartAfterSnapshotFailure( - options: OrcadDeployOptions, - incumbent: OrcadSlotIdentity, - lock: OrcadActivationLockControl -): Promise { - try { - await launchOrcadSlot(withoutAbortSignal(options), incumbent) - lock.recovered() - return `orcad ${incumbent.version} was restarted and is serving again.` - } catch (error) { - lock.retain() - return `restarting orcad ${incumbent.version} failed: ${errorMessage(error)} This host requires recovery.` - } -} - -/** Stop the candidate this run launched, then put the incumbent back only on safe state. */ -async function restoreAfterRejectedCandidate( - options: OrcadDeployOptions, - transaction: OrcadActivateTransaction, - incumbent: OrcadSlotIdentity | null, - lock: OrcadActivationLockControl -): Promise { - const recoveryOptions = withoutAbortSignal(options) - try { - const candidateDir = orcadSlotDir(options, transaction.candidateVersion) - const stopped = await stopOrcadSlot(recoveryOptions, candidateDir, true) - if (!orcadStopFreedTheHost(stopped)) { - lock.retain() - return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` - } - const recovery = await recoverOrcadIncumbent(recoveryOptions, { - transactionStartedAt: transaction.startedAt, - launchedVersion: transaction.candidateVersion, - incumbent, - restoreState: transaction.snapshot, - slotsProvenExited: true - }) - if (recovery.outcome === 'refused') { - lock.retain() - return recovery.reason - } - return incumbent - ? `orcad ${incumbent.version} was restarted and is serving again.` - : 'No previous version was active, so this host is now serving nothing.' - } catch (error) { - lock.retain() - return `Restoring the previous version failed: ${errorMessage(error)} This host requires recovery.` - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts b/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts deleted file mode 100644 index 37d27cd3c9a..00000000000 --- a/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { SshTarget } from '../../shared/ssh-types' -import type { ServeReadiness } from '../server/serve-readiness' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -export const MANAGED_VERSION = '0.2.0+abc' -export const MANAGED_PREVIOUS_VERSION = '0.1.0+def' -export const MANAGED_LOCAL_PORT = 46_768 - -/** A managed server registered in a temp profile, with its claimed SSH target. */ -export function createManagedLifecycleHarness() { - const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-managed-lifecycle-')) - let target: SshTarget = { - id: 'ssh-1', - label: 'Builder', - host: 'builder', - port: 22, - username: 'dev', - generation: 4, - owner: createManagedOrcadSshOwner('environment-1') - } - const environment = addManagedOrcadEnvironment(userDataPath, { - id: 'environment-1', - name: 'Managed', - pairingCode: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint: `ws://127.0.0.1:${MANAGED_LOCAL_PORT}/`, - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }), - orcadDeployment: { - sshTargetId: 'ssh-1', - sshTargetGeneration: 4, - localPort: MANAGED_LOCAL_PORT, - remotePort: 6_768 - } - }) - const flushes: number[] = [] - const claims = new SshTargetOrcadClaims({ - ...emptyDependentStateStore(), - allocateSshTargetGeneration: () => 5, - flushPendingOrThrowAsync: async () => { - flushes.push(flushes.length) - }, - getFolderWorkspaces: () => [], - getRepos: () => [], - getSshTarget: (id) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - const targetStore = { - getTarget: (id: string) => (id === target.id ? target : undefined), - getOrcadRuntimeClaims: () => claims - } - const context = (record: Record = {}) => ({ - activationRecord: { - active: MANAGED_VERSION, - previous: MANAGED_PREVIOUS_VERSION, - activatedAt: '2026-01-01T00:00:00.000Z', - snapshot: null, - ...record - }, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target, - userDataDir: '/home/dev/.orca' - }) - return { userDataPath, environment, targetStore, context, flushes, current: () => target } -} - -/** Readiness whose pairing offer, once tunneled, matches the harness server's saved one. */ -export function managedReadiness(deviceToken = 'device-token'): ServeReadiness { - const endpoint = 'ws://127.0.0.1:6768' - return { - runtimeId: 'runtime-1', - boundEndpoint: endpoint, - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken, - publicKeyB64: 'public-key' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} diff --git a/src/main/ssh/orcad-managed-remote-stop.ts b/src/main/ssh/orcad-managed-remote-stop.ts deleted file mode 100644 index 1200192d9db..00000000000 --- a/src/main/ssh/orcad-managed-remote-stop.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Stopping one remote orcad instance through its instance-bound request, never a signal. - * - * The client names the instance from two host records that must agree: the slot's readiness - * payload (runtime ID, PID, capability) and the data root's instance lock (PID, start time, - * nonce). The slot's own `orcad.js` then writes the request and proves exit on the host. - */ -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { parseOrcadReadinessOutput, readOrcadReadinessCommand } from './orcad-remote-launch' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { readBoundedOrcadRemoteRecord } from './orcad-remote-record-file' -import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' -import { joinRemotePath } from './ssh-remote-platform' -import { ORCAD_LOCK_FILE_NAME, parseOrcadInstanceLockRecord } from '../orcad/orcad-instance-lock' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG, - ORCAD_STOP_REQUESTS_CAPABILITY, - OrcadManagedStopCancellationSchema, - OrcadManagedStopCompletionSchema, - type OrcadManagedStopCancellation, - type OrcadManagedStopCompletion, - type OrcadManagedStopContext, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -const LOCK_RECORD_MAX_BYTES = 64 * 1024 - -export type OrcadManagedStopTarget = - | { state: 'ready'; context: OrcadManagedStopContext } - | { state: 'refused'; verdict: 'unverifiable'; code: string; reason: string } - -function refused(code: string, reason: string): OrcadManagedStopTarget { - return { state: 'refused', verdict: 'unverifiable', code, reason } -} - -/** Names the running instance of `version`, or says why the host could not prove which it is. */ -export async function readRemoteOrcadManagedStopTarget( - options: OrcadSlotOptions, - version: string -): Promise { - assertPosixOrcadHost(options.host) - const slotDir = orcadSlotDir(options, version) - const readiness = parseOrcadReadinessOutput( - await execOrcadRemote(options, readOrcadReadinessCommand(options.host, slotDir)) - ) - if (readiness.state !== 'ready' || !readiness.readiness.health) { - return refused( - 'orcad_managed_stop_readiness_unverifiable', - `orcad ${version} has no readable readiness record, so the running instance is unknown.` - ) - } - const { health, runtimeId } = readiness.readiness - if (health.stopRequests !== ORCAD_STOP_REQUESTS_CAPABILITY) { - return refused( - 'orcad_managed_stop_unsupported', - `orcad ${version} predates managed stop requests; it can only be stopped by signal.` - ) - } - const lockPath = joinRemotePath(options.host, options.userDataDir, ORCAD_LOCK_FILE_NAME) - const lockRead = await readBoundedOrcadRemoteRecord(options, lockPath, LOCK_RECORD_MAX_BYTES) - const lock = lockRead.state === 'present' ? parseOrcadInstanceLockRecord(lockRead.raw) : null - if (!lock || lock.pid !== health.pid || !runtimeId) { - return refused( - 'orcad_managed_stop_instance_unverifiable', - `The instance lock does not name the orcad ${version} that published readiness.` - ) - } - return { - state: 'ready', - context: { - version, - runtimeId, - instance: { pid: lock.pid, startedAtMs: lock.startedAtMs, nonce: lock.nonce, lockPath } - } - } -} - -export async function completeRemoteOrcadManagedStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - return OrcadManagedStopCompletionSchema.parse( - await runSlotCommand(options, request, ORCAD_COMPLETE_MANAGED_STOP_FLAG) - ) -} - -export async function cancelRemoteOrcadManagedStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - return OrcadManagedStopCancellationSchema.parse( - await runSlotCommand(options, request, ORCAD_CANCEL_MANAGED_STOP_FLAG) - ) -} - -/** Runs the slot's own build, which owns the request format it is asked to write. */ -async function runSlotCommand( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest, - flag: string -): Promise { - const slotDir = orcadSlotDir(options, request.version) - const entry = joinRemotePath(options.host, slotDir, 'orcad.js') - const output = await execOrcadRemote( - options, - `${selectOrcadSlotRuntimeCommand(options.host, slotDir, options.nodePath)} && ` + - `"$orcad_runtime" ${shellEscape(entry)} ${flag} ${shellEscape(JSON.stringify(request))}` - ) - const line = output - .trim() - .split('\n') - .findLast((candidate) => candidate.trim().startsWith('{')) - if (!line) { - throw new Error('orcad managed stop command returned no verifiable answer') - } - const parsed: unknown = JSON.parse(line) - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('orcad managed stop command returned no verifiable answer') - } - // The answer must be about this exact request, not another transaction's. - if (!('transactionId' in parsed) || parsed.transactionId !== request.transactionId) { - throw new Error('orcad managed stop command answered another transaction') - } - return parsed -} diff --git a/src/main/ssh/orcad-managed-runtime-context.ts b/src/main/ssh/orcad-managed-runtime-context.ts deleted file mode 100644 index 37e9c5d9daa..00000000000 --- a/src/main/ssh/orcad-managed-runtime-context.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { - KnownRuntimeEnvironment, - OrcadDeploymentLink -} from '../../shared/runtime-environments' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { ServeReadiness } from '../server/serve-readiness' -import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' -import { probeActiveOrcadReadiness } from './orcad-active-readiness' -import { resolveOrcadRemoteContext, type OrcadRemoteContext } from './orcad-remote-context' -import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir } from './ssh-relay-versioned-install' -import { getSshConnectionManager, getSshTargetRegistryStore } from './ssh-target-registry' - -export const ORCAD_BIND_HOST = '127.0.0.1' - -// Why empty: managed slots always carry their pinned runtime marker, so a marker-less slot -// fails to launch instead of silently running on whatever Node the host has. -export const MANAGED_ORCAD_LEGACY_NODE_PATH = '' - -export function requireManagedOrcadInfrastructure() { - const targetStore = requireManagedOrcadTargetStore() - const connectionManager = getSshConnectionManager() - if (!connectionManager) { - throw new Error('SSH is unavailable on this client; the managed Orca server is unverifiable.') - } - return { connectionManager, targetStore, claims: targetStore.getOrcadRuntimeClaims() } -} - -export function requireManagedOrcadTargetStore() { - const targetStore = getSshTargetRegistryStore() - if (!targetStore) { - throw new Error('SSH target state is unavailable; the managed Orca server is unverifiable.') - } - return targetStore -} - -export function requireManagedOrcadEnvironment( - userDataPath: string, - selector: string -): { environment: KnownRuntimeEnvironment; deployment: OrcadDeploymentLink } { - const environment = resolveEnvironment(userDataPath, selector) - const deployment = environment.orcadDeployment - if (!deployment || environment.connectionDependency !== 'ssh-tunnel') { - throw new Error('This server is not managed through an orcad SSH deployment.') - } - return { environment, deployment } -} - -export async function resolveLinkedOrcadContext( - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - signal?: AbortSignal -): Promise { - const { connectionManager, targetStore } = requireManagedOrcadInfrastructure() - const target = targetStore.getTarget(deployment.sshTargetId) - if ( - !target || - target.generation !== deployment.sshTargetGeneration || - getManagedOrcadOwnerEnvironmentId(target.owner) !== environment.id - ) { - throw new Error('The managed Orca server SSH registration is no longer valid.') - } - const connection = await connectionManager.connect(target) - return resolveOrcadRemoteContext(target, connection, signal) -} - -/** The slot options every remote lifecycle step takes for this managed server. */ -export function managedOrcadSlot(context: OrcadRemoteContext, port: number, signal?: AbortSignal) { - return { - conn: context.connection, - host: context.host, - remoteHome: context.remoteHome, - nodePath: MANAGED_ORCAD_LEGACY_NODE_PATH, - userDataDir: context.userDataDir, - bindHost: ORCAD_BIND_HOST, - port, - signal - } -} - -export function managedOrcadInstallDir(context: OrcadRemoteContext, version: string): string { - return computeRemoteInstallDir( - ORCAD_INSTALL_MODEL, - context.remoteHome, - version, - context.host.pathFlavor - ) -} - -/** The active slot's readiness, accepted only if it is the build this client holds. */ -export function probeManagedOrcadReadiness( - context: OrcadRemoteContext, - localOrcadDir: string, - fullVersion: string, - signal?: AbortSignal -): Promise { - return probeActiveOrcadReadiness( - { - conn: context.connection, - host: context.host, - remoteInstallDir: managedOrcadInstallDir(context, fullVersion), - signal - }, - { buildHash: computeLocalOrcadBuildHash(localOrcadDir), fullVersion } - ) -} - -// Why only this code: deploy never has a session count to force past, so forcing an unknown -// census lands on the daemon-protocol deferral, which force cannot clear. -export function isForceableOrcadDeferral(code: string): boolean { - return code === 'orcad_update_terminals_running' -} diff --git a/src/main/ssh/orcad-managed-tunnel-resume.ts b/src/main/ssh/orcad-managed-tunnel-resume.ts deleted file mode 100644 index 4f76704ed3c..00000000000 --- a/src/main/ssh/orcad-managed-tunnel-resume.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { - getPreferredPairingOffer, - getRuntimeSshAccess, - type KnownRuntimeEnvironment, - type RuntimeSshTunnelLink -} from '../../shared/runtime-environments' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import type { SshPortForwardManager } from './ssh-port-forward' -import type { getSshTargetRegistryStore } from './ssh-target-registry' - -export type ActiveOrcadTunnel = { - connection: SshConnection - forwardId: string - localPort: number - remotePort: number - sshTargetGeneration: number - targetId: string - transportGeneration: number -} - -export type OrcadManagedTunnelProbe = ( - environment: KnownRuntimeEnvironment, - timeoutMs: number -) => Promise - -export type OrcadManagedTunnelResumeOptions = { - attempts: number - resolveEnvironment: (environmentId: string) => KnownRuntimeEnvironment | null - timeoutMs: number -} - -type ResumeRecoveryDependencies = { - active: Map - forwards: SshPortForwardManager - getConnectionManager: () => SshConnectionManager | null - getManagerGeneration: () => number - getTargetStore: () => ReturnType - inFlight: Map> - ownershipGenerations: Map - probeTunnel?: OrcadManagedTunnelProbe -} - -type ResolvedManagedTunnelEnvironment = { - deployment: RuntimeSshTunnelLink - environment: KnownRuntimeEnvironment -} - -export class OrcadManagedTunnelResumeRecovery { - private readonly probeTunnel: OrcadManagedTunnelProbe - private resumeInFlight: Promise | null = null - - constructor(private readonly dependencies: ResumeRecoveryDependencies) { - this.probeTunnel = dependencies.probeTunnel ?? probeManagedOrcadTunnel - } - - dispose(): void { - this.resumeInFlight = null - } - - recover(options: OrcadManagedTunnelResumeOptions): Promise { - if (this.resumeInFlight) { - return this.resumeInFlight - } - const managerGeneration = this.dependencies.getManagerGeneration() - const recoveries = [...this.dependencies.active].map(([environmentId, active]) => - this.recoverActive(environmentId, active, managerGeneration, options) - ) - const operation = Promise.allSettled(recoveries) - .then((results) => { - const failed = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failed) { - throw failed.reason - } - }) - .finally(() => { - if (this.resumeInFlight === operation) { - this.resumeInFlight = null - } - }) - this.resumeInFlight = operation - return operation - } - - private async recoverActive( - environmentId: string, - active: ActiveOrcadTunnel, - managerGeneration: number, - options: OrcadManagedTunnelResumeOptions - ): Promise { - const ownershipGeneration = this.dependencies.ownershipGenerations.get(environmentId) ?? 0 - for (let attempt = 0; attempt < options.attempts; attempt++) { - const resolved = this.resolveEnvironment(environmentId, active, options) - if (!resolved) { - return - } - if (await this.probeTunnel(resolved.environment, options.timeoutMs)) { - return - } - if (!this.stillOwned(environmentId, active, ownershipGeneration, managerGeneration, true)) { - return - } - } - - const pending = this.dependencies.inFlight.get(environmentId) - if (pending) { - await pending.catch(() => undefined) - } - if (!this.stillOwned(environmentId, active, ownershipGeneration, managerGeneration, true)) { - return - } - const resolved = this.resolveEnvironment(environmentId, active, options) - const connectionManager = this.dependencies.getConnectionManager() - if ( - !resolved || - !connectionManager || - connectionManager.getConnection(active.targetId) !== active.connection - ) { - return - } - const operation = this.reconnectAndRebuild( - resolved.environment, - active, - connectionManager, - ownershipGeneration, - managerGeneration, - options - ).finally(() => { - if (this.dependencies.inFlight.get(environmentId) === operation) { - this.dependencies.inFlight.delete(environmentId) - } - }) - this.dependencies.inFlight.set(environmentId, operation) - await operation - } - - private async reconnectAndRebuild( - environment: KnownRuntimeEnvironment, - active: ActiveOrcadTunnel, - connectionManager: SshConnectionManager, - ownershipGeneration: number, - managerGeneration: number, - options: OrcadManagedTunnelResumeOptions - ): Promise { - await connectionManager.reconnect(active.targetId) - if ( - !this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true) || - connectionManager !== this.dependencies.getConnectionManager() || - connectionManager.getConnection(active.targetId) !== active.connection || - connectionManager.getState(active.targetId)?.status !== 'connected' || - active.connection.getTransportGeneration() <= active.transportGeneration - ) { - return - } - if (!this.resolveEnvironment(environment.id, active, options)) { - return - } - - const currentActive = this.dependencies.active.get(environment.id) - if (currentActive && currentActive !== active) { - return - } - if (currentActive === active) { - await this.dependencies.forwards.removeForwardAndWait(active.forwardId) - if (this.dependencies.active.get(environment.id) === active) { - this.dependencies.active.delete(environment.id) - } - } - if (!this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true)) { - return - } - - const current = this.resolveEnvironment(environment.id, active, options) - if (!current) { - return - } - const { deployment } = current - const transportGeneration = active.connection.getTransportGeneration() - const forward = await this.dependencies.forwards.addForward( - active.targetId, - active.connection, - deployment.localPort, - '127.0.0.1', - deployment.remotePort, - `Managed Orca server: ${current.environment.name}` - ) - if ( - forward.localPort !== deployment.localPort || - active.connection.getTransportGeneration() !== transportGeneration || - !this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true) || - !this.resolveEnvironment(environment.id, active, options) - ) { - await this.dependencies.forwards.removeForwardAndWait(forward.id) - if (forward.localPort !== deployment.localPort) { - throw new Error('Managed Orca tunnel bound an unexpected local port after host resume.') - } - return - } - this.dependencies.active.set(environment.id, { - connection: active.connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: active.sshTargetGeneration, - targetId: active.targetId, - transportGeneration - }) - } - - private resolveEnvironment( - environmentId: string, - active: ActiveOrcadTunnel, - options: OrcadManagedTunnelResumeOptions - ): ResolvedManagedTunnelEnvironment | null { - const environment = options.resolveEnvironment(environmentId) - const deployment = environment ? getRuntimeSshAccess(environment) : undefined - const target = this.dependencies.getTargetStore()?.getTarget(active.targetId) - if ( - !environment || - environment.connectionDependency !== 'ssh-tunnel' || - !deployment || - deployment.sshTargetId !== active.targetId || - deployment.sshTargetGeneration !== active.sshTargetGeneration || - deployment.localPort !== active.localPort || - deployment.remotePort !== active.remotePort || - !target || - target.generation !== active.sshTargetGeneration || - getManagedOrcadOwnerEnvironmentId(target.owner) !== environmentId - ) { - return null - } - return { deployment, environment } - } - - private stillOwned( - environmentId: string, - active: ActiveOrcadTunnel, - ownershipGeneration: number, - managerGeneration: number, - allowMissingActive = false - ): boolean { - const current = this.dependencies.active.get(environmentId) - return ( - this.dependencies.getManagerGeneration() === managerGeneration && - (this.dependencies.ownershipGenerations.get(environmentId) ?? 0) === ownershipGeneration && - (current === active || (allowMissingActive && current === undefined)) - ) - } -} - -async function probeManagedOrcadTunnel( - environment: KnownRuntimeEnvironment, - timeoutMs: number -): Promise { - try { - await sendRemoteRuntimeRequest( - getPreferredPairingOffer(environment), - 'status.get', - undefined, - timeoutMs - ) - return true - } catch { - return false - } -} diff --git a/src/main/ssh/orcad-managed-tunnel.test.ts b/src/main/ssh/orcad-managed-tunnel.test.ts deleted file mode 100644 index 420c04cfccf..00000000000 --- a/src/main/ssh/orcad-managed-tunnel.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createEnvironmentFromPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import type { SshConnectionStore } from './ssh-connection-store' -import type { SshPortForwardManager } from './ssh-port-forward' -import { OrcadManagedTunnelManager } from './orcad-managed-tunnel' - -function createEnvironment(linkKind: 'orcadDeployment' | 'sshAccess'): KnownRuntimeEnvironment { - const paired = createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Managed server', - now: 1, - offer: { - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }, - connectionDependency: 'ssh-tunnel' - }) - const access = { - sshTargetId: 'ssh-1', - sshTargetGeneration: 7, - localPort: 46_768, - remotePort: 6_768 - } - return linkKind === 'orcadDeployment' - ? { ...paired, orcadDeployment: access } - : { - ...paired, - sshAccess: { - ...access, - endpointId: paired.preferredEndpointId, - previousPreferredEndpointId: paired.preferredEndpointId - } - } -} - -function setup(overrides: Partial = {}) { - const target: SshTarget = { - id: 'ssh-1', - label: 'Managed server', - host: 'example.com', - port: 22, - username: 'deploy', - generation: 7, - owner: createManagedOrcadSshOwner('environment-1'), - ...overrides - } - let transportGeneration = 3 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const connection = { - getTransportGeneration: vi.fn(() => transportGeneration) - } as unknown as SshConnection - const connect = vi.fn().mockResolvedValue(connection) - const reconnect = vi.fn().mockImplementation(async () => { - transportGeneration += 1 - }) - const getConnection = vi.fn(() => connection) - const getState = vi.fn(() => ({ status: 'connected' })) - const probeTunnel = vi.fn().mockResolvedValue(true) - const addForward = vi - .fn() - .mockImplementation( - async ( - connectionId: string, - _connection: SshConnection, - localPort: number, - _remoteHost: string, - remotePort: number - ) => ({ - id: `forward-${addForward.mock.calls.length}`, - connectionId, - localPort, - remoteHost: '127.0.0.1', - remotePort - }) - ) - const removeForwardAndWait = vi.fn().mockResolvedValue(null) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const forwardManager = { - setCallbacks: vi.fn(), - addForward, - removeForwardAndWait, - dispose: vi.fn() - } as unknown as SshPortForwardManager - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const connectionManager = { - connect, - getConnection, - getState, - reconnect - } as unknown as SshConnectionManager - const manager = new OrcadManagedTunnelManager({ - getConnectionManager: () => connectionManager, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tunnel manager reads only getTarget. - getTargetStore: () => ({ getTarget: vi.fn(() => target) }) as unknown as SshConnectionStore, - forwardManager, - probeTunnel - }) - return { - addForward, - connect, - connection, - getConnection, - getState, - manager, - probeTunnel, - reconnect, - removeForwardAndWait, - target, - setTransportGeneration: (generation: number) => { - transportGeneration = generation - } - } -} - -describe('OrcadManagedTunnelManager initial binding', () => { - it.each(['close', 'dispose', 'reconnect'] as const)( - 'removes a late initial forward after %s supersedes setup', - async (action) => { - const state = setup() - state.addForward.mockImplementationOnce(async () => { - if (action === 'close') { - await state.manager.close('environment-1') - } - if (action === 'dispose') { - state.manager.dispose() - } - if (action === 'reconnect') { - state.setTransportGeneration(4) - } - return { id: 'late-initial-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await expect( - state.manager.start('environment-1', state.target, state.connection, 6_768) - ).rejects.toThrow('superseded') - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-initial-forward') - await state.manager.close('environment-1') - expect(state.removeForwardAndWait).toHaveBeenCalledTimes(1) - } - ) - - it('does not open a forward when canceled while the prior forward is closing', async () => { - const state = setup() - await state.manager.ensure(createEnvironment('sshAccess')) - state.removeForwardAndWait.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - }) - - await expect( - state.manager.start('environment-1', state.target, state.connection, 6_768) - ).rejects.toThrow('superseded') - - expect(state.addForward).toHaveBeenCalledTimes(1) - }) - - it('discards an older binding without closing the newer tunnel', async () => { - const state = setup() - let finishFirst!: () => void - state.addForward.mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => resolve({ id: 'old-forward', localPort: 46_768, remotePort: 6_768 }) - }) - ) - const first = state.manager.start('environment-1', state.target, state.connection, 6_768) - const rejected = expect(first).rejects.toThrow('superseded') - await vi.waitFor(() => expect(state.addForward).toHaveBeenCalledOnce()) - - await state.manager.start('environment-1', state.target, state.connection, 6_768) - finishFirst() - await rejected - - expect(state.removeForwardAndWait.mock.calls).toEqual([['old-forward']]) - await state.manager.close('environment-1') - expect(state.removeForwardAndWait.mock.calls).toEqual([['old-forward'], ['forward-2']]) - }) -}) - -describe.each(['orcadDeployment', 'sshAccess'] as const)( - 'OrcadManagedTunnelManager (%s)', - (linkKind) => { - const environment = () => createEnvironment(linkKind) - const resumeOptions = () => ({ - attempts: 2, - resolveEnvironment: () => environment(), - timeoutMs: 5_000 - }) - it('connects through the raw SSH manager and creates the exact loopback forward', async () => { - const state = setup() - - await state.manager.ensure(environment()) - - expect(state.connect).toHaveBeenCalledOnce() - if (linkKind === 'sshAccess') { - expect(environment().orcadDeployment).toBeUndefined() - } - expect(state.addForward).toHaveBeenCalledWith( - 'ssh-1', - expect.anything(), - 46_768, - '127.0.0.1', - 6_768, - 'Managed Orca server: Managed server' - ) - }) - - it('reuses a tunnel only for the same SSH transport generation', async () => { - const state = setup() - - await state.manager.ensure(environment()) - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledOnce() - - state.setTransportGeneration(4) - await state.manager.ensure(environment()) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('forward-1') - expect(state.addForward).toHaveBeenCalledTimes(2) - }) - - it.each(['ensure', 'resume'] as const)( - 'discards a forward when SSH reconnects during %s binding', - async (operation) => { - const state = setup() - if (operation === 'resume') { - await state.manager.ensure(environment()) - state.probeTunnel.mockResolvedValue(false) - } - state.addForward.mockImplementationOnce(async () => { - state.setTransportGeneration(9) - return { id: 'stale-forward', localPort: 46_768, remotePort: 6_768 } - }) - await (operation === 'resume' - ? state.manager.recoverAfterHostResume(resumeOptions()) - : state.manager.ensure(environment())) - expect(state.removeForwardAndWait).toHaveBeenCalledWith('stale-forward') - state.probeTunnel.mockClear() - await state.manager.recoverAfterHostResume(resumeOptions()) - expect(state.probeTunnel).not.toHaveBeenCalled() - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledTimes(operation === 'resume' ? 3 : 2) - } - ) - - it('fails closed when the SSH registration generation changed', async () => { - const state = setup({ generation: 8 }) - - await expect(state.manager.ensure(environment())).rejects.toThrow('removed or re-created') - expect(state.connect).not.toHaveBeenCalled() - }) - - it('fails closed when another environment owns the target', async () => { - const state = setup({ owner: createManagedOrcadSshOwner('environment-2') }) - - await expect(state.manager.ensure(environment())).rejects.toThrow('no longer owned') - expect(state.connect).not.toHaveBeenCalled() - }) - - it('coalesces concurrent tunnel preflights', async () => { - const state = setup() - let finishConnect!: () => void - state.connect.mockImplementationOnce( - () => - new Promise((resolve) => { - finishConnect = () => resolve(state.connection) - }) - ) - - const first = state.manager.ensure(environment()) - const second = state.manager.ensure(environment()) - finishConnect() - await Promise.all([first, second]) - - expect(state.connect).toHaveBeenCalledOnce() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not create a forward after close cancels an in-flight connection', async () => { - const state = setup() - state.connect.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - return state.connection - }) - - await state.manager.ensure(environment()) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('rechecks SSH ownership after connection resolves', async () => { - const state = setup() - state.connect.mockImplementationOnce(async () => { - state.target.owner = createManagedOrcadSshOwner('environment-2') - return state.connection - }) - - await state.manager.ensure(environment()) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('re-reads the saved environment after connection rather than trusting its initial snapshot', async () => { - const state = setup() - const original = environment() - let current = original - state.connect.mockImplementationOnce(async () => { - current = { - ...original, - pairingRevision: (original.pairingRevision ?? original.createdAt) + 1 - } - return state.connection - }) - - await state.manager.ensure(original, () => current) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('removes a newly bound forward if the saved environment disappears during binding', async () => { - const state = setup() - const original = environment() - let current: KnownRuntimeEnvironment | null = original - state.addForward.mockImplementationOnce(async () => { - current = null - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.ensure(original, () => current) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - }) - - it('removes a newly bound forward if the environment closes during binding', async () => { - const state = setup() - state.addForward.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.ensure(environment()) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - await state.manager.recoverAfterHostResume(resumeOptions()) - expect(state.probeTunnel).not.toHaveBeenCalled() - }) - - it('keeps a healthy managed tunnel intact after host resume', async () => { - const state = setup() - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.probeTunnel).toHaveBeenCalledOnce() - expect(state.probeTunnel).toHaveBeenCalledWith( - expect.objectContaining({ id: 'environment-1' }), - 5_000 - ) - expect(state.reconnect).not.toHaveBeenCalled() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - }) - - it('retries a failed wake probe before reconnecting', async () => { - const state = setup() - state.probeTunnel.mockResolvedValueOnce(false).mockResolvedValueOnce(true) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.probeTunnel).toHaveBeenCalledTimes(2) - expect(state.reconnect).not.toHaveBeenCalled() - }) - - it('reconnects and rebuilds the exact persisted port after failed wake probes', async () => { - const state = setup() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.reconnect).toHaveBeenCalledOnce() - expect(state.reconnect).toHaveBeenCalledWith('ssh-1') - expect(state.removeForwardAndWait).toHaveBeenCalledWith('forward-1') - expect(state.addForward).toHaveBeenLastCalledWith( - 'ssh-1', - state.connection, - 46_768, - '127.0.0.1', - 6_768, - 'Managed Orca server: Managed server' - ) - - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledTimes(2) - }) - - it('coalesces concurrent host-resume recoveries', async () => { - const state = setup() - let finishProbe!: () => void - state.probeTunnel.mockImplementationOnce( - () => - new Promise((resolve) => { - finishProbe = () => resolve(true) - }) - ) - await state.manager.ensure(environment()) - - const first = state.manager.recoverAfterHostResume(resumeOptions()) - const second = state.manager.recoverAfterHostResume(resumeOptions()) - expect(second).toBe(first) - finishProbe() - await Promise.all([first, second]) - - expect(state.probeTunnel).toHaveBeenCalledOnce() - }) - - it('does not reconnect an environment closed during its wake probe', async () => { - const state = setup() - let finishProbe!: () => void - state.probeTunnel.mockImplementationOnce( - () => - new Promise((resolve) => { - finishProbe = () => resolve(false) - }) - ) - await state.manager.ensure(environment()) - - const recovery = state.manager.recoverAfterHostResume({ - ...resumeOptions(), - attempts: 1 - }) - await vi.waitFor(() => expect(state.probeTunnel).toHaveBeenCalledOnce()) - await state.manager.close('environment-1') - finishProbe() - await recovery - - expect(state.reconnect).not.toHaveBeenCalled() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not rebuild when reconnect did not establish a newer transport', async () => { - const state = setup() - state.probeTunnel.mockResolvedValue(false) - state.reconnect.mockImplementationOnce(async () => undefined) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - attempts: 1 - }) - - expect(state.reconnect).toHaveBeenCalledOnce() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not rebuild after re-pairing removes SSH access during reconnect', async () => { - const state = setup() - let current = environment() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(current) - state.reconnect.mockImplementationOnce(async () => { - state.setTransportGeneration(4) - current = { ...current, orcadDeployment: undefined, sshAccess: undefined } - }) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - resolveEnvironment: () => current - }) - - expect(state.addForward).toHaveBeenCalledOnce() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - }) - - it('removes the replacement forward when re-pairing happens during resume binding', async () => { - const state = setup() - let current = environment() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(current) - state.addForward.mockImplementationOnce(async () => { - current = { ...current, orcadDeployment: undefined, sshAccess: undefined } - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - resolveEnvironment: () => current - }) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - }) - } -) diff --git a/src/main/ssh/orcad-managed-tunnel.ts b/src/main/ssh/orcad-managed-tunnel.ts deleted file mode 100644 index a50578e170b..00000000000 --- a/src/main/ssh/orcad-managed-tunnel.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { - getRuntimeSshAccess, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { SshTarget } from '../../shared/ssh-types' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import { SshPortForwardManager } from './ssh-port-forward' -import { - OrcadManagedTunnelResumeRecovery, - type ActiveOrcadTunnel, - type OrcadManagedTunnelProbe, - type OrcadManagedTunnelResumeOptions -} from './orcad-managed-tunnel-resume' -import { getSshConnectionManager, getSshTargetRegistryStore } from './ssh-target-registry' - -type OrcadManagedTunnelDependencies = { - getConnectionManager: () => SshConnectionManager | null - getTargetStore: () => ReturnType - forwardManager?: SshPortForwardManager - probeTunnel?: OrcadManagedTunnelProbe -} - -export class OrcadManagedTunnelManager { - private readonly active = new Map() - private readonly inFlight = new Map>() - private readonly ownershipGenerations = new Map() - private readonly forwards: SshPortForwardManager - private readonly resumeRecovery: OrcadManagedTunnelResumeRecovery - private managerGeneration = 0 - - constructor(private readonly dependencies: OrcadManagedTunnelDependencies) { - this.forwards = dependencies.forwardManager ?? new SshPortForwardManager() - this.resumeRecovery = new OrcadManagedTunnelResumeRecovery({ - active: this.active, - forwards: this.forwards, - getConnectionManager: dependencies.getConnectionManager, - getManagerGeneration: () => this.managerGeneration, - getTargetStore: dependencies.getTargetStore, - inFlight: this.inFlight, - ownershipGenerations: this.ownershipGenerations, - probeTunnel: dependencies.probeTunnel - }) - this.forwards.setCallbacks({ - onForwardClosed: (entry) => { - for (const [environmentId, active] of this.active) { - if (active.forwardId === entry.id) { - this.active.delete(environmentId) - } - } - } - }) - } - - ensure( - environment: KnownRuntimeEnvironment, - resolveCurrent: () => KnownRuntimeEnvironment | null = () => environment - ): Promise { - if (!getRuntimeSshAccess(environment)) { - return Promise.resolve() - } - const pending = this.inFlight.get(environment.id) - if (pending) { - return pending - } - const operation = this.ensureManagedTunnel(environment, resolveCurrent).finally(() => { - if (this.inFlight.get(environment.id) === operation) { - this.inFlight.delete(environment.id) - } - }) - this.inFlight.set(environment.id, operation) - return operation - } - - async start( - environmentId: string, - target: SshTarget, - connection: SshConnection, - remotePort: number - ): Promise { - if (!target.generation) { - throw new Error('Managed Orca SSH target has no registration generation.') - } - const managerGeneration = this.managerGeneration - const ownershipGeneration = (this.ownershipGenerations.get(environmentId) ?? 0) + 1 - const transportGeneration = connection.getTransportGeneration() - const stillCurrent = (): boolean => - this.managerGeneration === managerGeneration && - this.ownershipGenerations.get(environmentId) === ownershipGeneration && - connection.getTransportGeneration() === transportGeneration - await this.close(environmentId) - if (!stillCurrent()) { - throw new Error('Orca SSH tunnel setup was superseded.') - } - const forward = await this.forwards.addForward( - target.id, - connection, - 0, - '127.0.0.1', - remotePort, - `Managed Orca server` - ) - if (!stillCurrent()) { - await this.forwards.removeForwardAndWait(forward.id) - throw new Error('Orca SSH tunnel setup was superseded.') - } - this.active.set(environmentId, { - connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: target.generation, - targetId: target.id, - transportGeneration - }) - return forward.localPort - } - - async close(environmentId: string): Promise { - this.ownershipGenerations.set( - environmentId, - (this.ownershipGenerations.get(environmentId) ?? 0) + 1 - ) - const active = this.active.get(environmentId) - if (!active) { - return - } - this.active.delete(environmentId) - await this.forwards.removeForwardAndWait(active.forwardId) - } - - dispose(): void { - this.managerGeneration += 1 - this.active.clear() - this.inFlight.clear() - this.ownershipGenerations.clear() - this.resumeRecovery.dispose() - this.forwards.dispose() - } - - recoverAfterHostResume(options: OrcadManagedTunnelResumeOptions): Promise { - return this.resumeRecovery.recover(options) - } - - private async ensureManagedTunnel( - environment: KnownRuntimeEnvironment, - resolveCurrent: () => KnownRuntimeEnvironment | null - ): Promise { - const deployment = getRuntimeSshAccess(environment) - if (!deployment || environment.connectionDependency !== 'ssh-tunnel') { - throw new Error('Managed orcad environment is missing its SSH tunnel dependency.') - } - const targetStore = this.dependencies.getTargetStore() - const connectionManager = this.dependencies.getConnectionManager() - if (!targetStore || !connectionManager) { - throw new Error('SSH is unavailable on this client; the managed Orca server is unverifiable.') - } - const target = targetStore.getTarget(deployment.sshTargetId) - if (!target || target.generation !== deployment.sshTargetGeneration) { - throw new Error( - 'The SSH registration for this managed Orca server was removed or re-created.' - ) - } - if (getManagedOrcadOwnerEnvironmentId(target.owner) !== environment.id) { - throw new Error('The SSH target is no longer owned by this managed Orca server.') - } - - const managerGeneration = this.managerGeneration - const ownershipGeneration = this.ownershipGenerations.get(environment.id) ?? 0 - const stillOwned = (): boolean => { - const currentTarget = targetStore.getTarget(target.id) - const currentEnvironment = resolveCurrent() - const currentAccess = currentEnvironment ? getRuntimeSshAccess(currentEnvironment) : undefined - return ( - this.managerGeneration === managerGeneration && - (this.ownershipGenerations.get(environment.id) ?? 0) === ownershipGeneration && - currentTarget?.generation === target.generation && - getManagedOrcadOwnerEnvironmentId(currentTarget?.owner) === environment.id && - currentEnvironment?.id === environment.id && - currentEnvironment.runtimeId === environment.runtimeId && - (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === - (environment.pairingRevision ?? environment.createdAt) && - currentEnvironment.connectionDependency === 'ssh-tunnel' && - currentAccess?.sshTargetId === deployment.sshTargetId && - currentAccess.sshTargetGeneration === deployment.sshTargetGeneration && - currentAccess.localPort === deployment.localPort && - currentAccess.remotePort === deployment.remotePort - ) - } - const connection = await connectionManager.connect(target) - if (!stillOwned()) { - return - } - const transportGeneration = connection.getTransportGeneration() - const active = this.active.get(environment.id) - if ( - active?.connection === connection && - active.transportGeneration === transportGeneration && - active.targetId === target.id && - active.sshTargetGeneration === target.generation && - active.localPort === deployment.localPort && - active.remotePort === deployment.remotePort - ) { - return - } - if (active) { - await this.forwards.removeForwardAndWait(active.forwardId) - if (this.active.get(environment.id) === active) { - this.active.delete(environment.id) - } - } - if (!stillOwned()) { - return - } - const forward = await this.forwards.addForward( - target.id, - connection, - deployment.localPort, - '127.0.0.1', - deployment.remotePort, - `Managed Orca server: ${environment.name}` - ) - if (forward.localPort !== deployment.localPort) { - await this.forwards.removeForwardAndWait(forward.id) - throw new Error('Managed Orca tunnel bound an unexpected local port.') - } - if (!stillOwned() || connection.getTransportGeneration() !== transportGeneration) { - await this.forwards.removeForwardAndWait(forward.id) - return - } - this.active.set(environment.id, { - connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: target.generation, - targetId: target.id, - transportGeneration - }) - } -} - -const managedTunnels = new OrcadManagedTunnelManager({ - getConnectionManager: getSshConnectionManager, - getTargetStore: getSshTargetRegistryStore -}) - -export async function ensureOrcadManagedTunnel( - userDataPath: string, - selector: string -): Promise { - const environment = resolveEnvironment(userDataPath, selector) - await managedTunnels.ensure(environment, () => { - try { - return resolveEnvironment(userDataPath, environment.id) - } catch { - return null - } - }) -} - -export function disposeOrcadManagedTunnels(): void { - managedTunnels.dispose() -} - -export function recoverOrcadManagedTunnelsAfterHostResume( - userDataPath: string, - options: { attempts: number; timeoutMs: number } -): Promise { - return managedTunnels.recoverAfterHostResume({ - ...options, - resolveEnvironment: (environmentId) => { - try { - return resolveEnvironment(userDataPath, environmentId) - } catch { - return null - } - } - }) -} - -export function startOrcadManagedTunnel( - environmentId: string, - target: SshTarget, - connection: SshConnection, - remotePort: number -): Promise { - return managedTunnels.start(environmentId, target, connection, remotePort) -} - -export function closeOrcadManagedTunnel(environmentId: string): Promise { - return managedTunnels.close(environmentId) -} diff --git a/src/main/ssh/orcad-managed-update-deferrals.ts b/src/main/ssh/orcad-managed-update-deferrals.ts deleted file mode 100644 index 1bf0285f2b2..00000000000 --- a/src/main/ssh/orcad-managed-update-deferrals.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** The last update each managed server deferred in this session, so status can report it. */ -import type { OrcadManagedDeferral } from '../../shared/orcad-managed-runtime' - -type RecordedDeferral = OrcadManagedDeferral & { deferredAt: string } - -const deferrals = new Map() - -export function recordManagedOrcadUpdateDeferral( - environmentId: string, - deferral: OrcadManagedDeferral, - now = new Date() -): void { - deferrals.set(environmentId, { ...deferral, deferredAt: now.toISOString() }) -} - -export function clearManagedOrcadUpdateDeferral(environmentId: string): void { - deferrals.delete(environmentId) -} - -export function readManagedOrcadUpdateDeferral(environmentId: string): RecordedDeferral | null { - return deferrals.get(environmentId) ?? null -} diff --git a/src/main/ssh/orcad-migration-manifest-export.test.ts b/src/main/ssh/orcad-migration-manifest-export.test.ts deleted file mode 100644 index 8637dca12b2..00000000000 --- a/src/main/ssh/orcad-migration-manifest-export.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../shared/project-group-types' -import type { Repo } from '../../shared/repo-types' -import type { SshTarget } from '../../shared/ssh-types' -import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' -import { emptyDormantPayload } from '../persistence/migrating-orcad-catalog/orcad-source-dormant-state' -import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 7 -} - -function repo(id: string, connectionId: string, projectGroupId?: string): Repo { - return { - id, - path: `/srv/${id}`, - displayName: id, - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId, - ...(projectGroupId ? { projectGroupId } : {}) - } -} - -function group( - id: string, - connectionId: string | null, - parentGroupId: string | null = null -): ProjectGroup { - return { - id, - name: id, - parentPath: `/srv/${id}`, - connectionId, - parentGroupId, - createdFrom: 'manual', - tabOrder: 1, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function folder(id: string, projectGroupId: string, connectionId?: string): FolderWorkspace { - return { - id, - projectGroupId, - name: id, - folderPath: `/srv/${id}`, - ...(connectionId ? { connectionId } : {}), - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } -} - -describe('orcad migration manifest export', () => { - it('exports only the target catalog plus referenced group ancestry', () => { - const groups = [ - group('parent', null), - group('repo-group', 'ssh-prod', 'parent'), - group('folder-group', 'ssh-prod'), - group('other-group', 'ssh-other') - ] - const manifest = createOrcadMigrationManifest( - { - collectOrcadMigrationSourceDormantState: emptyDormantPayload, - getRepos: () => [ - repo('repo-prod', 'ssh-prod', 'repo-group'), - repo('repo-other', 'ssh-other') - ], - getProjectGroups: () => groups, - getFolderWorkspaces: () => [ - folder('folder-inherited', 'folder-group'), - folder('folder-explicit', 'folder-group', 'ssh-prod'), - folder('folder-other', 'other-group', 'ssh-other') - ] - }, - TARGET, - { migrationId: 'migration-1', now: () => new Date('2026-08-30T12:00:00.000Z') } - ) - - expect(manifest.payload.repositories.map((entry) => entry.id)).toEqual(['repo-prod']) - expect(manifest.payload.projectGroups.map((entry) => entry.id)).toEqual([ - 'parent', - 'repo-group', - 'folder-group' - ]) - expect(manifest.payload.folderWorkspaces.map((entry) => entry.id)).toEqual([ - 'folder-inherited', - 'folder-explicit' - ]) - expect(manifest.source).toEqual({ - sshTargetId: 'ssh-prod', - sshTargetGeneration: 7, - targetLabel: 'Production' - }) - const { manifestSha256, ...unsigned } = manifest - expect(manifestSha256).toBe(computeOrcadMigrationManifestSha256(unsigned)) - }) - - it('records a null generation for a legacy registration without mutating it', () => { - const target = { ...TARGET, generation: undefined } - const manifest = createOrcadMigrationManifest( - { - collectOrcadMigrationSourceDormantState: emptyDormantPayload, - getRepos: () => [], - getProjectGroups: () => [], - getFolderWorkspaces: () => [] - }, - target, - { migrationId: 'migration-legacy', now: () => new Date('2026-08-30T12:00:00.000Z') } - ) - - expect(manifest.source.sshTargetGeneration).toBeNull() - expect(target.generation).toBeUndefined() - }) - - it('includes a session-only dormant payload', () => { - const dormant = emptyDormantPayload() - dormant.workspaceSession = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - 'repo-prod::/srv/worktree': [ - { - id: 'tab-dormant', - ptyId: null, - worktreeId: 'repo-prod::/srv/worktree', - title: 'Dormant', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - const manifest = createOrcadMigrationManifest( - { - collectOrcadMigrationSourceDormantState: () => dormant, - getRepos: () => [repo('repo-prod', TARGET.id)], - getProjectGroups: () => [], - getFolderWorkspaces: () => [] - }, - TARGET, - { migrationId: 'migration-session-only' } - ) - - expect(manifest.payload.dormantState?.workspaceSession?.tabsByWorktree).toHaveProperty( - 'repo-prod::/srv/worktree' - ) - }) -}) diff --git a/src/main/ssh/orcad-migration-manifest-export.ts b/src/main/ssh/orcad-migration-manifest-export.ts deleted file mode 100644 index a94c7610dab..00000000000 --- a/src/main/ssh/orcad-migration-manifest-export.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * The signed manifest a relay-hosted SSH target's state is exported as. - * - * Reads only: the catalog rows the target owns and the dormant state that references them, copied - * out of the profile-state store. The source keeps every row; retiring it happens only after the - * destination has verified and imported this exact manifest. - */ -import { randomUUID } from 'node:crypto' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest, - type OrcadMigrationDormantStatePayload, - type OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' -import type { SshTarget } from '../../shared/ssh-types' -import type { Store } from '../persistence' -import { collectOrcadMigrationSourceCatalog } from '../persistence/migrating-orcad-catalog/orcad-source-catalog' -import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' - -export type OrcadMigrationExportStore = Pick< - Store, - | 'collectOrcadMigrationSourceDormantState' - | 'getFolderWorkspaces' - | 'getProjectGroups' - | 'getRepos' -> - -export function createOrcadMigrationManifest( - store: OrcadMigrationExportStore, - target: SshTarget, - options: { migrationId?: string; now?: () => Date; destinationEnvironmentId?: string } = {} -): OrcadMigrationManifest { - const payload = collectOrcadMigrationSourceCatalog(store, target) - const source = { - sshTargetId: target.id, - sshTargetGeneration: target.generation ?? null, - targetLabel: target.label - } - const dormantState = store.collectOrcadMigrationSourceDormantState( - source, - payload, - options.destinationEnvironmentId - ) - const unsigned = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: options.migrationId ?? randomUUID(), - createdAt: (options.now ?? (() => new Date()))().toISOString(), - source, - payload: { - ...payload, - ...(hasDormantState(dormantState) ? { dormantState } : {}) - }, - ...(options.destinationEnvironmentId - ? { destinationEnvironmentId: options.destinationEnvironmentId } - : {}) - } - return parseOrcadMigrationManifest({ - ...unsigned, - manifestSha256: computeOrcadMigrationManifestSha256(unsigned) - }) -} - -function hasDormantState(state: OrcadMigrationDormantStatePayload): boolean { - return ( - state.worktreeMeta.length > 0 || - state.worktreeLineage.length > 0 || - state.workspaceLineage.length > 0 || - state.sparsePresets.length > 0 || - state.retiredWorktreeNames.length > 0 || - state.retiredWorktreeNamespaces.length > 0 || - state.workspaceSession !== undefined || - (state.terminalScrollbackSnapshots?.length ?? 0) > 0 || - (state.automations?.length ?? 0) > 0 || - (state.automationRuns?.length ?? 0) > 0 || - (state.clientState !== undefined && Object.keys(state.clientState).length > 0) - ) -} diff --git a/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts b/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts deleted file mode 100644 index 2a1b44170e4..00000000000 --- a/src/main/ssh/orcad-migration-snapshot-coordinator.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createHash } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - type OrcadMigrationCatalogState, - type OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' -import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../shared/orcad-migration-scrollback' -import { transferOrcadMigrationSnapshots } from './orcad-migration-snapshot-coordinator' - -const BYTES = Buffer.from('resume these bytes', 'utf8') -const SNAPSHOT: OrcadMigrationTerminalScrollbackSnapshot = { - tabId: 'tab-1', - leafId: 'leaf-1', - ref: `v1-${'1'.repeat(32)}`, - sha256: createHash('sha256').update(BYTES).digest('hex'), - byteLength: BYTES.length -} -const MANIFEST: OrcadMigrationManifest = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-1', - createdAt: '2026-08-30T12:00:00.000Z', - source: { sshTargetId: 'source', sshTargetGeneration: 1, targetLabel: 'Source' }, - payload: { - repositories: [], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - terminalScrollbackSnapshots: [SNAPSHOT] - } - }, - manifestSha256: 'a'.repeat(64) -} -type ChunkReader = ( - manifest: OrcadMigrationManifest, - ref: string, - offset: number -) => { bytesBase64: string; totalBytes: number; eof: boolean } - -function source(read: ChunkReader) { - return { - readOrcadMigrationSourceSnapshotChunk: read, - retainOrcadMigrationScrollback: vi.fn(), - releaseOrcadMigrationScrollback: vi.fn() - } -} - -const unreachableRead: ChunkReader = () => { - throw new Error('must not read') -} - -describe('orcad migration snapshot coordinator', () => { - it('resumes at the observed offset and reconciles a lost chunk response', async () => { - const initialOffset = 4 - const sourceRead = vi.fn(() => ({ - bytesBase64: BYTES.subarray(initialOffset).toString('base64'), - totalBytes: BYTES.length, - eof: true - })) - const store = source(sourceRead) - const snapshotRequest = vi.fn() - const remoteReads = [staged(BYTES.length), staged(BYTES.length)] - - await transferOrcadMigrationSnapshots({ - source: store, - manifest: MANIFEST, - state: staged(initialOffset), - destination: { - stageChunk: async (request) => { - snapshotRequest(request) - throw new Error('response lost') - }, - readState: async () => nextState(remoteReads) - } - }) - - expect(sourceRead).toHaveBeenCalledWith(MANIFEST, SNAPSHOT.ref, initialOffset) - // The bytes stay retained exactly for the transfer's duration. - expect(store.retainOrcadMigrationScrollback).toHaveBeenCalledWith(MANIFEST) - expect(store.releaseOrcadMigrationScrollback).toHaveBeenCalledWith(MANIFEST.migrationId) - expect(snapshotRequest).toHaveBeenCalledWith( - expect.objectContaining({ offset: initialOffset, ref: SNAPSHOT.ref }) - ) - expect(remoteReads).toEqual([]) - }) - - it('fails closed when an old host omits snapshot upload state', async () => { - const sourceRead = vi.fn(unreachableRead) - const store = source(sourceRead) - await expect( - transferOrcadMigrationSnapshots({ - source: store, - manifest: MANIFEST, - state: staged(), - destination: { - stageChunk: async () => { - throw new Error('must not upload') - }, - readState: async () => staged() - } - }) - ).rejects.toThrow('orcad_migration_snapshot_transfer_unsupported') - expect(sourceRead).not.toHaveBeenCalled() - // A failed transfer still releases what it retained. - expect(store.releaseOrcadMigrationScrollback).toHaveBeenCalledOnce() - }) - - it('refuses a snapshot whose source length changed since export', async () => { - await expect( - transferOrcadMigrationSnapshots({ - source: source(() => ({ - bytesBase64: Buffer.from('changed').toString('base64'), - totalBytes: BYTES.length + 1, - eof: true - })), - manifest: MANIFEST, - state: staged(0), - destination: { - stageChunk: async () => { - throw new Error('must not upload') - }, - readState: async () => staged(0) - } - }) - ).rejects.toThrow('orcad_migration_source_snapshot_changed') - }) - - it('requires complete upload evidence after the final chunk', async () => { - const remoteReads = [staged(BYTES.length - 1)] - await expect( - transferOrcadMigrationSnapshots({ - source: source(unreachableRead), - manifest: MANIFEST, - state: staged(BYTES.length), - destination: { - stageChunk: async () => { - throw new Error('must not upload') - }, - readState: async () => nextState(remoteReads) - } - }) - ).rejects.toThrow('orcad_migration_snapshot_transfer_incomplete') - }) -}) - -function staged(receivedBytes?: number): Extract { - return { - state: 'staged', - migrationId: MANIFEST.migrationId, - manifestSha256: MANIFEST.manifestSha256, - stagedAt: '2026-08-30T12:01:00.000Z', - ...(receivedBytes === undefined ? {} : { snapshotUploads: [{ ...SNAPSHOT, receivedBytes }] }) - } -} - -function nextState( - states: Extract[] -): Extract { - const state = states.shift() - if (!state) { - throw new Error('unexpected remote read') - } - return state -} diff --git a/src/main/ssh/orcad-migration-snapshot-coordinator.ts b/src/main/ssh/orcad-migration-snapshot-coordinator.ts deleted file mode 100644 index 86e129f345f..00000000000 --- a/src/main/ssh/orcad-migration-snapshot-coordinator.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Sending a manifest's scrollback snapshots to the destination in bounded, resumable chunks. - * - * The source side only reads: each chunk comes from the profile-state store, checked against the - * signed manifest's length and digest. The destination's catalog operations are passed in, so - * this driver owns no transport and retires nothing. A chunk whose acknowledgement was lost is - * confirmed by reading the destination's recorded offset, never assumed. - */ -import type { - OrcadMigrationCatalogState, - OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' -import { - decodeOrcadMigrationSnapshotChunk, - type OrcadMigrationSnapshotChunkRequest, - type OrcadMigrationSnapshotChunkResult -} from '../../shared/orcad-migration-scrollback' -import type { Store } from '../persistence' - -export type OrcadMigrationSnapshotSource = Pick< - Store, - | 'readOrcadMigrationSourceSnapshotChunk' - | 'retainOrcadMigrationScrollback' - | 'releaseOrcadMigrationScrollback' -> - -export type OrcadMigrationSnapshotDestination = { - readState: (manifest: OrcadMigrationManifest) => Promise - stageChunk: ( - request: OrcadMigrationSnapshotChunkRequest - ) => Promise -} - -export async function transferOrcadMigrationSnapshots(args: { - source: OrcadMigrationSnapshotSource - manifest: OrcadMigrationManifest - /** The destination's staged state, whose upload offsets say where each snapshot resumes. */ - state: Extract - destination: OrcadMigrationSnapshotDestination -}): Promise { - const snapshots = args.manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - if (snapshots.length === 0) { - return - } - // Closing a tab mid-transfer must not delete the bytes this manifest promised. - args.source.retainOrcadMigrationScrollback(args.manifest) - try { - await sendSnapshots(args, snapshots) - } finally { - args.source.releaseOrcadMigrationScrollback(args.manifest.migrationId) - } -} - -async function sendSnapshots( - args: Parameters[0], - snapshots: NonNullable< - NonNullable['terminalScrollbackSnapshots'] - > -): Promise { - const offsets = new Map( - (args.state.snapshotUploads ?? []).map((entry) => [entry.ref, entry.receivedBytes]) - ) - for (const snapshot of snapshots) { - let offset = offsets.get(snapshot.ref) - if (offset === undefined) { - throw new Error('orcad_migration_snapshot_transfer_unsupported') - } - while (offset < snapshot.byteLength) { - const chunk = args.source.readOrcadMigrationSourceSnapshotChunk( - args.manifest, - snapshot.ref, - offset - ) - if (chunk.totalBytes !== snapshot.byteLength || !chunk.bytesBase64) { - throw new Error('orcad_migration_source_snapshot_changed') - } - const request: OrcadMigrationSnapshotChunkRequest = { - migrationId: args.manifest.migrationId, - manifestSha256: args.manifest.manifestSha256, - ref: snapshot.ref, - offset, - bytesBase64: chunk.bytesBase64 - } - const expectedOffset = offset + decodeOrcadMigrationSnapshotChunk(chunk.bytesBase64).length - offset = await stageChunkWithRecovery(args, request, expectedOffset) - } - } - const verified = await args.destination.readState(args.manifest) - if ( - verified.state !== 'staged' || - (verified.snapshotUploads ?? []).length !== snapshots.length || - (verified.snapshotUploads ?? []).some((entry) => entry.receivedBytes !== entry.byteLength) - ) { - throw new Error('orcad_migration_snapshot_transfer_incomplete') - } -} - -async function stageChunkWithRecovery( - args: { manifest: OrcadMigrationManifest; destination: OrcadMigrationSnapshotDestination }, - request: OrcadMigrationSnapshotChunkRequest, - expectedOffset: number -): Promise { - try { - const result = await args.destination.stageChunk(request) - if (result.acknowledgedOffset !== expectedOffset) { - throw new Error('orcad_migration_snapshot_ack_invalid') - } - return result.acknowledgedOffset - } catch (error) { - try { - const observed = await args.destination.readState(args.manifest) - const received = - observed.state === 'staged' - ? observed.snapshotUploads?.find((entry) => entry.ref === request.ref)?.receivedBytes - : undefined - if (received === expectedOffset) { - return received - } - } catch { - // The chunk stays unverifiable; report the first failure. - } - throw error - } -} diff --git a/src/main/ssh/orcad-recovery-slot.ts b/src/main/ssh/orcad-recovery-slot.ts deleted file mode 100644 index 8e4cec5a768..00000000000 --- a/src/main/ssh/orcad-recovery-slot.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Slot-level verdicts shared by activation, rollback and their crash recovery. - * - * Every verdict here is the execution host's: `exited` only on positive proof, and a probe - * that could not answer is `unverifiable` — never a reason to start a second slot. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import type { OrcadActivationExpectation } from './orcad-activation-gate' -import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir } from './ssh-relay-versioned-install' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { - launchOrcadSlotAndAwaitReadiness, - OrcadActiveReadinessError, - probeActiveOrcadReadiness -} from './orcad-active-readiness' -import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' -import { - parseOrcadStopOutcome, - stopOrcadCommand, - type OrcadStopOutcome -} from './orcad-remote-process-control' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { - initialOrcadActivationAdmissionCommand, - parseInitialOrcadActivationAdmission -} from './orcad-initial-activation-admission' - -export const ORCAD_SLOT_STOP_WAIT_SECONDS = 20 - -export type OrcadSlotOptions = OrcadRemoteExecTarget & { - remoteHome: string - /** Host Node for legacy slots only; a slot's own runtime marker wins. */ - nodePath: string - userDataDir: string - bindHost: string - port: number - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise -} - -export type OrcadSlotIdentity = { version: string; remoteDir: string; buildHash: string } - -function expectation(identity: OrcadSlotIdentity): OrcadActivationExpectation { - return { buildHash: identity.buildHash, fullVersion: identity.version } -} - -export function orcadSlotDir(options: OrcadSlotOptions, version: string): string { - return computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, version) -} - -/** Reads the installed bytes' hash, so a later readiness payload can be matched to them. */ -export async function resolveOrcadSlotIdentity( - options: OrcadSlotOptions, - version: string -): Promise { - const remoteDir = orcadSlotDir(options, version) - return { version, remoteDir, buildHash: await readRemoteOrcadBuildHash(options, remoteDir) } -} - -export function launchOrcadSlot( - options: OrcadSlotOptions, - identity: OrcadSlotIdentity -): Promise { - return launchOrcadSlotAndAwaitReadiness( - options, - { - remoteInstallDir: identity.remoteDir, - nodePath: options.nodePath, - fullVersion: identity.version, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }, - expectation(identity) - ) -} - -/** Proves the slot serves, starting it only on proven exit. */ -export async function ensureOrcadSlotServing( - options: OrcadSlotOptions, - identity: OrcadSlotIdentity -): Promise { - const liveness = parseOrcadLiveness( - await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, identity.remoteDir)) - ) - if (liveness === 'LIVE') { - return probeActiveOrcadReadiness( - { ...options, remoteInstallDir: identity.remoteDir }, - expectation(identity) - ) - } - if (liveness === 'UNKNOWN') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${identity.version} process state is unverifiable.` - ) - } - return launchOrcadSlot(options, identity) -} - -/** `justLaunched` only for a slot this run started; otherwise the readiness PID must agree. */ -export async function stopOrcadSlot( - options: OrcadSlotOptions, - remoteDir: string, - justLaunched: boolean -): Promise { - return parseOrcadStopOutcome( - await execOrcadRemote( - options, - stopOrcadCommand( - options.host, - remoteDir, - justLaunched - ? { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, justLaunched: true } - : { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, nodePath: options.nodePath } - ) - ) - ) -} - -export type OrcadSlotQuiescence = 'exited' | 'other-slot-serving' - -/** - * Before an interrupted run's state is replaced: the slot it started must be proven exited and - * the slot being restored must not be running, or must already be serving (nothing to undo). - * - * A missing PID file is not proof of exit: an SSH drop can kill the launcher after `nohup` - * but before it records `$!`, so the data root's owner records decide. - */ -export async function quiesceInterruptedOrcadSlot( - options: OrcadSlotOptions, - /** `null` when nothing was launched, so only `otherSlot` needs ruling out. */ - version: string | null, - otherSlot: OrcadSlotIdentity | null -): Promise { - const remoteDir = version === null ? null : orcadSlotDir(options, version) - const stopped = - remoteDir === null ? 'already-exited' : await stopOrcadSlot(options, remoteDir, false) - let exited = stopped === 'stopped' || stopped === 'already-exited' - if (stopped === 'unknown' && remoteDir !== null) { - // The readiness PID never matched, e.g. the slot died before readiness; liveness decides. - exited = (await slotLiveness(options, remoteDir)) === 'DEAD' - } - if (otherSlot) { - const other = await slotLiveness(options, otherSlot.remoteDir) - if (other === 'LIVE' && (exited || stopped === 'no-pid')) { - // The serving slot holds the instance lock, so a PID-less launch cannot own the state. - await probeActiveOrcadReadiness( - { ...options, remoteInstallDir: otherSlot.remoteDir }, - expectation(otherSlot) - ) - return 'other-slot-serving' - } - if (other !== 'DEAD') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${version ?? 'candidate'} (${stopped}) and ${otherSlot.version} (${other}) cannot both be ` + - 'ruled out as owners of the shared state; it was not replaced.' - ) - } - } - if (!exited && stopped === 'no-pid' && remoteDir !== null) { - const admission = parseInitialOrcadActivationAdmission( - await execOrcadRemote( - options, - initialOrcadActivationAdmissionCommand( - options.host, - options.userDataDir, - remoteDir, - options.nodePath - ) - ) - ) - exited = admission.decision === 'proceed' - } - if (!exited) { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${version ?? 'candidate'} could not be confirmed stopped (${stopped}); replacing its state would be unsafe.` - ) - } - return 'exited' -} - -async function slotLiveness( - options: OrcadSlotOptions, - remoteDir: string -): Promise<'LIVE' | 'DEAD' | 'UNKNOWN'> { - return parseOrcadLiveness( - await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, remoteDir)) - ) -} diff --git a/src/main/ssh/orcad-remote-build-hash.ts b/src/main/ssh/orcad-remote-build-hash.ts deleted file mode 100644 index 7ed6384dfe7..00000000000 --- a/src/main/ssh/orcad-remote-build-hash.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** The installed slot's `orcad.js` identity, the same 16-hex prefix orcad reports in its health. */ -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const BUILD_HASH_MARKER = '__ORCAD_BUILD_HASH__' - -export async function readRemoteOrcadBuildHash( - target: OrcadRemoteExecTarget, - remoteInstallDir: string -): Promise { - const output = await execOrcadRemote( - target, - remoteOrcadBuildHashCommand(target.host, remoteInstallDir) - ) - const match = output.match(/__ORCAD_BUILD_HASH__\s+([a-fA-F0-9]{16})/u) - if (!match?.[1]) { - throw new Error('Could not verify the installed orcad build hash.') - } - return match[1].toLowerCase() -} - -export function remoteOrcadBuildHashCommand( - host: RemoteHostPlatform, - remoteInstallDir: string -): string { - assertPosixOrcadHost(host) - const path = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js')) - // Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. - return [ - `orca_hash=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum ${path} | awk '{print $1}';`, - `elif command -v shasum >/dev/null 2>&1; then shasum -a 256 ${path} | awk '{print $1}'; fi);`, - `case "$orca_hash" in [0-9a-fA-F][0-9a-fA-F]*) printf '%s %.16s\\n' ${shellEscape(BUILD_HASH_MARKER)} "$orca_hash";; esac` - ].join(' ') -} diff --git a/src/main/ssh/orcad-remote-context.ts b/src/main/ssh/orcad-remote-context.ts deleted file mode 100644 index 6b893ba2950..00000000000 --- a/src/main/ssh/orcad-remote-context.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Everything a managed-orcad operation needs to know about one SSH host before it acts. */ -import type { ServerTarget } from '../../shared/node-runtime-pin' -import type { SshTarget } from '../../shared/ssh-types' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import type { SshConnection } from './ssh-connection' -import { readRemoteHomeCommand } from './ssh-remote-commands' -import { - joinRemotePath, - normalizeRemoteHome, - validateRemoteHome, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' - -export type OrcadRemoteContext = { - activationRecord: OrcadActivationRecord - serverTarget: ServerTarget - connection: SshConnection - host: RemoteHostPlatform - remoteHome: string - target: SshTarget - userDataDir: string -} - -export async function resolveOrcadRemoteContext( - target: SshTarget, - connection: SshConnection, - signal?: AbortSignal -): Promise { - const host = await detectRemoteHostPlatform(connection, { signal }) - if (!host) { - throw new Error('This SSH host platform is not supported by managed orcad.') - } - // Why first: every lifecycle step after this is POSIX-only, so refuse before probing further. - assertPosixOrcadHost(host) - const remote = { conn: connection, host, signal } - const remoteHome = normalizeRemoteHome( - await execOrcadRemote(remote, readRemoteHomeCommand(host)), - host - ) - if (!validateRemoteHome(remoteHome, host)) { - throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) - } - const serverTarget = await resolveOrcadDeploymentTarget({ conn: connection, host, signal }) - const activationRecord = await readOrcadActivationRecord({ ...remote, remoteHome }) - return { - activationRecord, - serverTarget, - connection, - host, - remoteHome, - target, - userDataDir: joinRemotePath(host, remoteHome, '.orca') - } -} diff --git a/src/main/ssh/orcad-remote-decommission.test.ts b/src/main/ssh/orcad-remote-decommission.test.ts deleted file mode 100644 index 492fcd03e5e..00000000000 --- a/src/main/ssh/orcad-remote-decommission.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn() -})) -vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn() -})) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn() -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' -import { decommissionRemoteOrcad } from './orcad-remote-stop' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { parseOrcadActivationRecord } from './orcad-activation-record' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' -import { FakeOrcadHost, OLD, type CrashMode } from './orcad-activation-host-test-harness' - -let host = new FakeOrcadHost() - -const slot = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u', - nodePath: '/usr/bin/node', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777, - readinessTimeoutMs: 50, - sleep: async () => {}, - now: () => new Date('2026-02-02T00:00:00.000Z') -} -const idle: OrcadTerminalCensus = { - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 -} - -function currentRecord() { - const parsed = parseOrcadActivationRecord(host.record) - if (parsed.state !== 'ok') { - throw new Error('fixture record is unreadable') - } - return parsed.record -} - -const decommission = (census: OrcadTerminalCensus = idle) => - decommissionRemoteOrcad({ ...slot, record: currentRecord(), census }) - -beforeEach(() => { - vi.clearAllMocks() - host = FakeOrcadHost.deployedOld() - vi.mocked(execCommand).mockImplementation(async (_conn, command) => host.exec(command)) - vi.mocked(acquireInstallLock).mockImplementation(async () => host.acquireFence()) - vi.mocked(writeAtomicOrcadRemoteRecord).mockImplementation(async (_target, path, contents) => - host.write(path, contents) - ) -}) - -function expectExactlyTheRecordedSlot(): void { - const active = host.activeVersion() - expect([...host.alive]).toEqual(active ? [active] : []) - expect(host.journal).toBeNull() - expect(host.fence).toBe(false) -} - -describe('decommissioning a managed orcad', () => { - it('stops the active instance by request, then records that nothing serves', async () => { - expect(await decommission()).toEqual({ - outcome: 'decommissioned', - version: OLD, - retirement: 'retired' - }) - expect(currentRecord()).toMatchObject({ active: null, previous: OLD, snapshot: null }) - expectExactlyTheRecordedSlot() - // Never a signal: the instance-bound request is the only stop path. - expect(host.commands.some((command) => command.includes('kill -TERM'))).toBe(false) - }) - - it.each([ - [{ ...idle, liveSessions: null }, 'unverifiable', 'orcad_decommission_census_unavailable'], - [{ ...idle, liveSessions: 2 }, 'live', 'orcad_decommission_terminals_running'] - ] as const)('refuses while the census says %j', async (census, verdict, code) => { - expect(await decommission(census)).toMatchObject({ outcome: 'refused', verdict, code }) - expect(host.alive.has(OLD)).toBe(true) - expect(writeAtomicOrcadRemoteRecord).not.toHaveBeenCalled() - }) - - it('refuses an orcad that cannot be addressed by request', async () => { - host.alive.clear() - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_managed_stop_readiness_unverifiable' - }) - expect(writeAtomicOrcadRemoteRecord).not.toHaveBeenCalled() - }) - - it('withdraws a request orcad never acted on and keeps serving', async () => { - host.managedStop = 'stays' - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_decommission_stop_withdrawn' - }) - expect(currentRecord().active).toBe(OLD) - expectExactlyTheRecordedSlot() - }) - - it('keeps the fence while orcad is still stopping, and recovery finishes once it exits', async () => { - host.managedStop = 'stays-dispatched' - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_decommission_stop_unsettled' - }) - expect(host.fence).toBe(true) - expect(host.journal).not.toBeNull() - expect(await recoverInterruptedOrcadActivation(slot)).toMatchObject({ - outcome: 'refused', - code: 'orcad_recovery_decommission_unsettled' - }) - host.managedStop = 'exits' - expect(await recoverInterruptedOrcadActivation(slot)).toMatchObject({ - outcome: 'recovered', - resolution: 'committed', - activeVersion: null - }) - expectExactlyTheRecordedSlot() - }) - - it.each(['before', 'after'])( - 'recovers to exactly the recorded slot when interrupted at every mutation (%s)', - async (mode) => { - host = FakeOrcadHost.deployedOld() - await decommission() - const total = host.mutations - expect(total).toBeGreaterThan(3) - for (let crashAt = 1; crashAt <= total; crashAt += 1) { - host = FakeOrcadHost.deployedOld() - host.crashAt = crashAt - host.crashMode = mode - await decommission().catch(() => undefined) - host.crashAt = null - const result = await recoverInterruptedOrcadActivation(slot) - expect(['recovered', 'none'], `mutation ${crashAt}`).toContain(result.outcome) - expectExactlyTheRecordedSlot() - } - } - ) -}) diff --git a/src/main/ssh/orcad-remote-deploy-stop.ts b/src/main/ssh/orcad-remote-deploy-stop.ts new file mode 100644 index 00000000000..8a00a1dfa03 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy-stop.ts @@ -0,0 +1,68 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { + parseOrcadStopOutcome, + stopOrcadCommand, + type OrcadStopOutcome +} from './orcad-remote-process-control' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { compareOrcadStateSnapshotCommand, orcadSnapshotIsUnchanged } from './orcad-state-snapshot' + +export type OrcadOutgoingStopOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + nodePath: string + signal?: AbortSignal +} + +/** Stop the outgoing runtime and return its execution-host verdict. */ +export async function stopOutgoingOrcad( + options: OrcadOutgoingStopOptions, + outgoingVersion: string +): Promise { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + outgoingVersion + ) + const output = await execCommand( + options.conn, + stopOrcadCommand(options.host, outgoingDir, { waitSeconds: 20, nodePath: options.nodePath }), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) + return parseOrcadStopOutcome(output) +} + +/** The caller must confirm candidate exit before inspecting its shared state. */ +export async function rejectedOrcadStateRecoveryRefusal( + options: OrcadOutgoingStopOptions & { userDataDir: string }, + incumbentVersion: string, + snapshotDir: string | undefined +): Promise { + const unchanged = snapshotDir + ? orcadSnapshotIsUnchanged( + await execCommand( + options.conn, + compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir), + { wrapCommand: options.host.commandDialect !== 'powershell', signal: options.signal } + ).catch(() => '') + ) + : false + if (unchanged) { + return undefined + } + // RPC was already exposed; a prelaunch census cannot authorize discarding candidate writes. + const retainedSnapshot = snapshotDir ? ` at ${snapshotDir}.` : ', which is unavailable.' + return ( + 'The candidate is stopped, but profile state changed or could not be verified. ' + + `orcad ${incumbentVersion} was not restarted against potentially incompatible state. ` + + 'Current state and daemon terminals are preserved; recovery requires a fresh host ' + + `terminal census before restoring the prelaunch snapshot${retainedSnapshot}` + ) +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 29af8774db9..8308ff52db5 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -1,5 +1,4 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' -import type * as RecordFile from './orcad-remote-record-file' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,10 +17,6 @@ vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: vi.fn().mockResolvedValue(undefined), writeRelayFile: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) -})) vi.mock('./orcad-remote-node-runtime', () => ({ ensureRemoteOrcadNodeRuntime: vi.fn().mockResolvedValue(undefined) })) @@ -31,8 +26,7 @@ vi.mock('./orcad-local-build-hash', () => ({ import { execCommand } from './ssh-relay-deploy-helpers' import { acquireInstallLock } from './ssh-relay-install-lock' -import { uploadRelayDirectory } from './ssh-relay-install-transfers' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' import { installOrcadBundle } from './orcad-remote-install' import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime' @@ -59,7 +53,6 @@ vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ })) function readyLine(overrides: { - version?: string buildHash?: string daemonState?: 'live' | 'degraded' | 'absent' selfTestOk?: boolean @@ -74,7 +67,7 @@ function readyLine(overrides: { pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, health: { buildHash: overrides.buildHash ?? 'abc123def4567890', - buildVersion: overrides.version ?? NEW_VERSION, + buildVersion: NEW_VERSION, nodeVersion: '20.11.0', nodeAbi: '115', platform: 'linux', @@ -113,21 +106,10 @@ type HostScript = { function scriptHost(script: HostScript): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) - if (text.includes('__ORCAD_RECORD_PRESENT__') && text.includes('orcad-active.json')) { + if (text.startsWith('cat ') && text.includes('orcad-active.json')) { return script.activationRecord - ? `__ORCAD_RECORD_PRESENT__\n${script.activationRecord}` - : '__ORCAD_RECORD_ABSENT__\n' } - if (text.includes('__ORCAD_RECORD_PRESENT__') && text.includes('transaction.json')) { - return '__ORCAD_RECORD_ABSENT__\n' - } - if (text.includes('__ORCAD_BUILD_HASH__')) { - return '__ORCAD_BUILD_HASH__ abc123def4567890\n' - } - if (text.includes('orcad.lock') && text.includes('orca-runtime.json')) { - return 'CLEAR' - } - if (text.includes('.orcad-readiness') && text.startsWith('head -c ')) { + if (text.includes('.orcad-readiness') && text.startsWith('cat ')) { if (script.readinessAtMs !== undefined && Date.now() < script.readinessAtMs) { return '' } @@ -180,7 +162,7 @@ function options(overrides: Partial = {}): OrcadDeployOption userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, readinessTimeoutMs: 50, sleep: async () => {}, now: () => new Date('2026-02-02T00:00:00.000Z'), @@ -290,8 +272,8 @@ describe('deployOrcad', () => { expect(script.log).toEqual(['preflight']) expect( vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some(([, path]) => path.includes('orcad-active.json')) + .mocked(writeRelayFile) + .mock.calls.some(([, , path]) => path.includes('orcad-active.json')) ).toBe(false) } ) @@ -325,7 +307,7 @@ describe('deployOrcad', () => { options({ host: getRemoteHostPlatform(platform), target, - census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 1, startedSinceActivation: 0 } }) ) const chmod = mockExec.mock.calls.findIndex(([, command]) => @@ -374,7 +356,7 @@ describe('deployOrcad', () => { if (String(command).startsWith('chmod 755 ')) { throw new Error('chmod failed') } - return String(command).includes('__ORCAD_RECORD_ABSENT__') ? '__ORCAD_RECORD_ABSENT__\n' : '' + return '' }) await expect(deployOrcad(options())).rejects.toThrow('chmod failed') expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() @@ -459,9 +441,9 @@ describe('deployOrcad', () => { const result = await deployOrcad(options()) expect(result).toMatchObject({ outcome: 'installed-and-activated', fullVersion: NEW_VERSION }) const written = vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.find((call) => String(call[1]).endsWith('orcad-active.json')) - expect(JSON.parse(String(written?.[2]))).toMatchObject({ + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ active: NEW_VERSION, previous: OLD_VERSION }) @@ -488,7 +470,7 @@ describe('deployOrcad', () => { } scriptHost(script) const result = await deployOrcad( - options({ census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 } }) + options({ census: { liveSessions: 2, startedSinceActivation: 0 } }) ) expect(result).toMatchObject({ outcome: 'installed-not-activated', @@ -510,8 +492,8 @@ describe('deployOrcad', () => { expect(result).toMatchObject({ code: 'orcad_activation_daemon_degraded' }) expect( vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some((call) => String(call[1]).endsWith('orcad-active.json')) + .mocked(writeRelayFile) + .mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json')) ).toBe(false) }) @@ -520,7 +502,7 @@ describe('deployOrcad', () => { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }), - [OLD_VERSION]: readyLine({ version: OLD_VERSION }) + [OLD_VERSION]: readyLine({}) }, log: [] } @@ -578,7 +560,7 @@ describe('deployOrcad', () => { it('restarts the incumbent when a quiescent snapshot cannot be captured', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, - readiness: { [OLD_VERSION]: readyLine({ version: OLD_VERSION }) }, + readiness: { [OLD_VERSION]: readyLine({}) }, log: [], snapshotResult: 'tar: write failed' } @@ -621,7 +603,7 @@ describe('deployOrcad', () => { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({ buildHash: 'deadbeefdeadbeef' }), - [OLD_VERSION]: readyLine({ version: OLD_VERSION }) + [OLD_VERSION]: readyLine({}) }, log: [] } diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 0e74a106a38..f296e08bd36 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -2,31 +2,52 @@ * Activate installed bytes only after the candidate proves healthy. A rejected candidate * allows restarting the incumbent only when profile state is provably unchanged; otherwise * preserve current state and the prelaunch snapshot for explicit recovery. - * - * Every activation runs under the host's activation fence and journal - * (`orcad-activation-lock.ts`), so an interrupted one is recoverable to exactly one slot. */ -import { join } from 'node:path' import type { SshConnection } from './ssh-connection' +import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' +import { execCommand } from './ssh-relay-deploy-helpers' import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { writeRelayFile } from './ssh-relay-install-transfers' import { computeRemoteInstallDir, readLocalFullVersion } from './ssh-relay-versioned-install' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import type { OrcadActivationVerdict } from './orcad-activation-gate' -import type { OrcadTerminalCensus } from './orcad-update-plan' -import type { RemoteHostPlatform } from './ssh-remote-platform' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, + withActivatedVersion, + type OrcadActivationRecord, + type OrcadStateSnapshot +} from './orcad-activation-record' +import { orcadActivationPath, readOrcadActivationRecord } from './orcad-activation-record-store' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { planOrcadUpdate, type OrcadTerminalCensus } from './orcad-update-plan' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand +} from './orcad-remote-launch' +import { rejectedOrcadStateRecoveryRefusal, stopOutgoingOrcad } from './orcad-remote-deploy-stop' +import { + captureOrcadStateSnapshotCommand, + orcadSnapshotDirName, + parseOrcadSnapshotCapture +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' +import { preflightInstalledOrcad } from './orcad-remote-preflight' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { installOrcadBundle } from './orcad-remote-install' +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { ServerTarget } from '../../shared/node-runtime-pin' -import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { materializeOrcadArtifact } from './orcad-artifact-materializer' import { readOrcadBundleTarget, resolveOrcadDeploymentTarget } from './orcad-deployment-target' import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' -import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock -} from './orcad-activation-lock' -import { activateInstalledOrcad } from './orcad-installed-activation' export type OrcadDeployOptions = { conn: SshConnection @@ -60,6 +81,129 @@ export type OrcadDeployResult = | { outcome: 'already-active'; fullVersion: string } | { outcome: 'installed-not-activated'; fullVersion: string; code: string; reason: string } +const READINESS_POLL_MS = 500 +const STOP_WAIT_SECONDS = 20 + +function exec(options: OrcadDeployOptions, command: string): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }) +} + +function baseDir(options: OrcadDeployOptions): string { + return joinRemotePath(options.host, options.remoteHome, RELAY_REMOTE_DIR) +} + +async function captureSnapshot( + options: OrcadDeployOptions, + fullVersion: string, + outgoingVersion: string | null, + takenAt: Date +): Promise { + // The caller has already stopped the outgoing runtime. This is required once profile state + // includes SQLite: a tar of a live WAL, main database, and SHM file is not a SQLite backup. + const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime()) + const snapshotDir = joinRemotePath( + options.host, + baseDir(options), + ORCAD_STATE_SNAPSHOT_DIR, + dirName + ) + const capture = parseOrcadSnapshotCapture( + await exec( + options, + captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) + ) + ) + if (capture === 'failed') { + throw new Error( + `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + + 'persisted state carries no schema version, so without a snapshot a rollback has no ' + + 'way back. Refusing to activate.' + ) + } + // Empty profiles need no rollback snapshot. + if (capture === 'empty') { + return null + } + return { + dirName, + takenBeforeVersion: fullVersion, + readableByVersion: outgoingVersion, + takenAt: takenAt.toISOString() + } +} + +async function launchAndAwaitReadiness( + options: OrcadDeployOptions, + remoteInstallDir: string, + fullVersion: string +): Promise> { + await exec( + options, + orcadLaunchCommand(options.host, { ...options, remoteInstallDir, fullVersion }) + ) + const deadline = Date.now() + (options.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let last = parseOrcadReadinessOutput('') + while (Date.now() < deadline) { + options.signal?.throwIfAborted() + last = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, remoteInstallDir)) + ) + if (last.state !== 'pending') { + return last + } + await sleep(READINESS_POLL_MS) + } + return last +} + +/** Restart the incumbent only when the candidate left shared state unchanged. */ +async function restoreIncumbent( + options: OrcadDeployOptions, + record: OrcadActivationRecord, + candidateDir?: string, + snapshot?: OrcadStateSnapshot | null +): Promise { + if (candidateDir) { + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, candidateDir, { + waitSeconds: STOP_WAIT_SECONDS, + justLaunched: true + }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + } + } + if (!record.active) { + return 'No previous version was active, so this host is now serving nothing.' + } + if (candidateDir) { + const snapshotDir = snapshot + ? joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, snapshot.dirName) + : undefined + const refusal = await rejectedOrcadStateRecoveryRefusal(options, record.active, snapshotDir) + if (refusal) { + return refusal + } + } + const incumbentDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + record.active + ) + const parsed = await launchAndAwaitReadiness(options, incumbentDir, record.active) + return parsed.state === 'ready' + ? `orcad ${record.active} was restarted and is serving again.` + : `orcad ${record.active} was relaunched but has not published readiness; this host may be down.` +} + /** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ export async function deployOrcad(input: OrcadDeployOptions): Promise { assertPosixOrcadHost(input.host) @@ -71,10 +215,6 @@ export async function deployOrcad(input: OrcadDeployOptions): Promise materializeNodeRuntimeArchive( target, @@ -84,14 +224,108 @@ export async function deployOrcad(input: OrcadDeployOptions): Promise new Date()) const fullVersion = readLocalFullVersion(options.localOrcadDir) const remoteDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, fullVersion) - // Fail fast before upload; the activation re-reads it under the fence. - await readOrcadActivationRecord(options) + const record = await readOrcadActivationRecord(options) await installOrcadBundle(options, fullVersion, remoteDir) - return withOrcadActivationLock(options, (lock) => - activateInstalledOrcad(options, fullVersion, remoteDir, lock) + const plan = planOrcadUpdate({ + record, + candidateVersion: fullVersion, + census: options.census, + ...(options.force !== undefined ? { force: options.force } : {}) + }) + if (plan.action === 'noop') { + return { outcome: 'already-active', fullVersion } + } + if (plan.action === 'defer') { + return { + outcome: 'installed-not-activated', + fullVersion, + code: plan.code, + reason: plan.reason + } + } + + try { + await preflightInstalledOrcad({ + ...options, + remoteInstallDir: remoteDir, + fullVersion + }) + } catch (error) { + options.signal?.throwIfAborted() + return { + outcome: 'installed-not-activated', + fullVersion, + code: 'orcad_candidate_preflight_failed', + reason: `Candidate profile preflight failed; the incumbent was not stopped: ${ + error instanceof Error ? error.message : String(error) + }` + } + } + + if (record.active) { + const stopped = await stopOutgoingOrcad(options, record.active) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'installed-not-activated', + fullVersion, + code: 'orcad_outgoing_stop_incomplete', + reason: + `Could not verify that orcad ${record.active} exited (${stopped}). ` + + 'No snapshot was taken and the candidate was not started. Orca requires matching ' + + 'runtime readiness before signaling an incumbent and confirmed exit before snapshotting.' + } + } + } + + // A live SQLite WAL is not a backup boundary: tar can observe the main file, WAL and SHM + // at different points and restore a set SQLite cannot recover. Stop the incumbent first so + // its final durable flush has completed before capturing the pre-activation state. + let snapshot: OrcadStateSnapshot | null = null + if (record.active) { + try { + snapshot = await captureSnapshot(options, fullVersion, record.active, now()) + } catch (error) { + const restored = await restoreIncumbent(options, record).catch( + (restartError: unknown) => + `The incumbent could not be restarted: ${ + restartError instanceof Error ? restartError.message : String(restartError) + }` + ) + throw new Error( + `${error instanceof Error ? error.message : String(error)} The incumbent was stopped ` + + `before snapshotting; ${restored}` + ) + } + } + + const parsed = await launchAndAwaitReadiness(options, remoteDir, fullVersion) + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), + fullVersion + }) + if (verdict.decision === 'reject') { + const restored = await restoreIncumbent(options, record, remoteDir, snapshot) + return { + outcome: 'installed-not-activated', + fullVersion, + code: verdict.code, + reason: + `${verdict.reason} Candidate stderr is at ` + + `${joinRemotePath(options.host, remoteDir, ORCAD_LOG_FILENAME)}. ${restored}` + } + } + + await writeRelayFile( + options.conn, + options.host, + orcadActivationPath(options.host, options.remoteHome), + serializeOrcadActivationRecord(withActivatedVersion(record, fullVersion, snapshot, now())), + { signal: options.signal } ) + return { outcome: 'installed-and-activated', fullVersion, verdict } } diff --git a/src/main/ssh/orcad-remote-gc.test.ts b/src/main/ssh/orcad-remote-gc.test.ts index 974677d45f5..044bd974e98 100644 --- a/src/main/ssh/orcad-remote-gc.test.ts +++ b/src/main/ssh/orcad-remote-gc.test.ts @@ -23,8 +23,6 @@ vi.mock('./ssh-relay-install-lock', () => ({ import { execCommand } from './ssh-relay-deploy-helpers' import { gcOldOrcadVersions } from './orcad-remote-gc' import { emptyOrcadActivationRecord } from './orcad-activation-record' -import { serializeOrcadActivationTransaction } from './orcad-activation-transaction' -import { createOrcadActivationTransaction } from './orcad-activation-transaction-transitions' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' @@ -41,22 +39,8 @@ function scriptHost(options: { listing: string[] liveness?: Record removed: string[] - /** The activation journal: absent by default, raw contents, or a read with no answer. */ - journal?: string | Error - fenceHeld?: boolean }): void { mockExec.mockImplementation(async (_conn, command: string) => { - if (command.includes('__ORCAD_RECORD_PRESENT__') && command.includes('transaction.json')) { - if (options.journal instanceof Error) { - throw options.journal - } - return options.journal === undefined - ? '__ORCAD_RECORD_ABSENT__\n' - : `__ORCAD_RECORD_PRESENT__\n${options.journal}` - } - if (command.includes('.orcad-activation-transaction') && command.includes('LOCKED')) { - return options.fenceHeld ? 'LOCKED' : 'OPEN' - } if (command.includes('-mindepth 1 -maxdepth 1')) { return options.listing.join('\n') } @@ -200,8 +184,7 @@ describe('orcad GC', () => { ).rejects.toBe(error) expect(removed).toEqual([]) - // Journal read and fence probe, then the GC pass up to the unconfirmed probe. - expect(mockExec).toHaveBeenCalledTimes(6) + expect(mockExec).toHaveBeenCalledTimes(4) expect(mockExec.mock.calls.at(-1)?.[1]).toContain('.orcad-pid') }) @@ -243,57 +226,4 @@ describe('orcad GC', () => { await gcOldOrcadVersions({ ...options, nodeRuntimePins: ['a'.repeat(64)] }) expect(inventories()).toBe(1) }) - - describe('with an activation transaction in flight', () => { - const listing = ['orcad-0.1.0+01d', 'orcad-0.2.0+9ee0', 'orcad-0.3.0+cc0'] - const record = { ...emptyOrcadActivationRecord(), active: '0.3.0+cc0' } - const run = () => - gcOldOrcadVersions({ - conn, - host, - remoteHome: '/home/u', - currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', - record - }) - - it('keeps every slot a pending journal names', async () => { - const removed: string[] = [] - const before = { ...record, previous: '0.1.0+01d' } - scriptHost({ - listing, - removed, - journal: serializeOrcadActivationTransaction( - createOrcadActivationTransaction({ - transactionId: '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f', - candidateVersion: '0.2.0+9ee0', - recordBefore: before, - snapshotDirName: 'pre-0.2.0+9ee0-1', - now: new Date(0) - }) - ) - }) - await run() - expect(removed).toEqual([]) - }) - - it.each([ - ['an unreadable journal', { journal: '{"schemaVersion":99}' }], - ['a journal read with no answer', { journal: new Error('lost') }], - ['a held fence without a journal', { fenceHeld: true }] - ])('collects nothing behind %s', async (_name, state) => { - const removed: string[] = [] - vi.spyOn(console, 'warn').mockImplementation(() => {}) - scriptHost({ listing, removed, ...state }) - await run() - expect(removed).toEqual([]) - expect(mockExec.mock.calls.some(([, command]) => command.includes('-mindepth 1'))).toBe(false) - }) - - it('never names a snapshot or rescue copy as a candidate', async () => { - const removed: string[] = [] - scriptHost({ listing: [...listing, 'orcad-state-snapshots'], removed }) - await run() - expect(removed).not.toContain('orcad-state-snapshots') - }) - }) }) diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts index 5f7664664e4..2f1692201e4 100644 --- a/src/main/ssh/orcad-remote-gc.ts +++ b/src/main/ssh/orcad-remote-gc.ts @@ -10,8 +10,7 @@ * * On top of the ownership rule, orcad pins three directories that are idle-looking but * load-bearing: the active version, the rollback target, and whichever version the LIVE - * terminal daemon was forked from. Every version an in-flight activation journal names is - * pinned too, and an unreadable journal skips the pass entirely. + * terminal daemon was forked from. */ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' @@ -26,7 +25,6 @@ import { } from './orcad-remote-launch' import type { RemoteHostPlatform } from './ssh-remote-platform' import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import { readOrcadGcTransactionPins } from './orcad-gc-transaction-pins' export type OrcadGcOptions = { conn: SshConnection @@ -52,11 +50,6 @@ export type OrcadGcOptions = { } export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise { - const transaction = await readOrcadGcTransactionPins(options) - if (transaction.state === 'keep-all') { - console.warn('[orcad-gc] An activation transaction is unreadable or unjournaled; skipping GC.') - return - } await gcOldRemoteInstallVersions( options.conn, ORCAD_INSTALL_MODEL, @@ -64,10 +57,7 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise options.currentDirAbsPath, options.host, { - pinnedDirNames: [ - ...orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), - ...transaction.dirNames - ], + pinnedDirNames: orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), isDirLive: async (dir) => { try { const probe = await execCommand( diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index 6e7a6644ec0..d6c9640ad33 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -21,14 +21,11 @@ import { } from './orcad-remote-host-support' import type { ServeReadiness } from '../server/serve-readiness' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' /** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ export const ORCAD_READINESS_FILENAME = '.orcad-readiness' /** Stderr, including the bind-exposure line and every supervision message. */ export const ORCAD_LOG_FILENAME = 'orcad.log' -// Why a cap: the readiness file is candidate-written stdout, and a runaway writer must not be read whole. -const ORCAD_READINESS_MAX_BYTES = 256 * 1024 export { ORCAD_PID_FILENAME, OrcadRemoteLaunchUnsupportedError } from './orcad-remote-host-support' export type OrcadLaunchSpec = { @@ -64,8 +61,6 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp // Why truncate: a re-launch into a dir that already holds a previous readiness line would // otherwise let the deploy activate on the OLD process's health payload. `: > ${readiness} &&`, - // A stop request the previous process never consumed must not stop this one. - `rm -f ${shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME))} &&`, 'umask 077 &&', `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, @@ -83,8 +78,7 @@ export function readOrcadReadinessCommand( ): string { assertPosixHost(host) const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - // One byte over the cap is enough to tell an oversized payload from a full one. - return `head -c ${ORCAD_READINESS_MAX_BYTES + 1} ${readiness} 2>/dev/null || true` + return `cat ${readiness} 2>/dev/null || true` } /** @@ -140,23 +134,19 @@ export type OrcadReadinessParse = * not `malformed` — reporting a parse failure for a race would fail deploys that were fine. */ export function parseOrcadReadinessOutput(raw: string): OrcadReadinessParse { - if (Buffer.byteLength(raw, 'utf8') > ORCAD_READINESS_MAX_BYTES) { - return { state: 'malformed', reason: 'readiness payload exceeds the 256 KiB limit' } - } const lines = raw.split('\n') - for (const [index, line] of lines.entries()) { + let sawCandidate = false + for (const line of lines) { const trimmed = line.trim() if (!trimmed.startsWith('{')) { continue } + sawCandidate = true let parsed: unknown try { parsed = JSON.parse(trimmed) } catch { - // Only the unterminated last line can still be mid-write; a finished bad line will stay bad. - return index === lines.length - 1 - ? { state: 'pending' } - : { state: 'malformed', reason: 'readiness line is not valid JSON' } + continue } if (typeof parsed !== 'object' || parsed === null) { continue @@ -170,7 +160,7 @@ export function parseOrcadReadinessOutput(raw: string): OrcadReadinessParse { } return { state: 'ready', readiness: toServeReadiness(payload as Record) } } - return { state: 'pending' } + return sawCandidate ? { state: 'pending' } : { state: 'pending' } } function toServeReadiness(payload: Record): ServeReadiness { diff --git a/src/main/ssh/orcad-remote-primitives.test.ts b/src/main/ssh/orcad-remote-primitives.test.ts deleted file mode 100644 index 881f0db21f6..00000000000 --- a/src/main/ssh/orcad-remote-primitives.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn(), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && error.message === 'channel lost' -})) - -vi.mock('./ssh-remote-platform-detection', () => ({ detectRemoteHostPlatform: vi.fn() })) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' -import { resolveOrcadRemoteContext } from './orcad-remote-context' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { emptyOrcadActivationRecord } from './orcad-activation-record' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { - launchOrcadSlotAndAwaitReadiness, - OrcadActiveReadinessError, - probeActiveOrcadReadiness -} from './orcad-active-readiness' -import { parseOrcadReadinessOutput } from './orcad-remote-launch' - -const mockExec = vi.mocked(execCommand) -const linux = getRemoteHostPlatform('linux-x64') -const windows = getRemoteHostPlatform('win32-x64') -const conn: SshConnection = Object.create(null) -const target = { conn, host: linux } -const BUILD_HASH = 'abc123def4567890' - -function readyLine( - buildHash = BUILD_HASH, - daemon: { coverage?: 'pty-spawn' | 'handshake'; platform?: string } = {} -): string { - const selfTest = { ok: true, verdict: 'healthy', durationMs: 5 } - return JSON.stringify({ - type: 'orca_server_ready', - runtimeId: 'r1', - boundEndpoint: 'ws://127.0.0.1:7777', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, - health: { - buildHash, - buildVersion: '0.2.0+bb01', - nodeVersion: '24.21.0', - nodeAbi: '137', - platform: daemon.platform ?? 'linux', - arch: 'x64', - pid: 1, - terminalDaemon: { - state: 'live', - ownsFreshSessions: true, - pid: 2, - buildVersion: '0.2.0+bb01', - entryPath: '/x/daemon-entry.js', - protocolVersion: 38, - selfTest: - 'coverage' in daemon - ? { ...selfTest, ...(daemon.coverage ? { coverage: daemon.coverage } : {}) } - : { ...selfTest, coverage: 'pty-spawn' } - } - } - }) -} - -beforeEach(() => { - mockExec.mockReset() -}) - -describe('orcad host record files', () => { - it('tells an absent record from one whose read gave no answer', async () => { - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_ABSENT__\n') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).resolves.toEqual({ - state: 'absent' - }) - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_PRESENT__\n{"a":1}') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).resolves.toEqual({ - state: 'present', - raw: '{"a":1}' - }) - mockExec.mockResolvedValueOnce('') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).rejects.toThrow( - 'no verifiable answer' - ) - }) - - it('keeps the partial file when the write may still be running on the host', async () => { - mockExec.mockRejectedValueOnce(new Error('channel lost')) - await expect(writeAtomicOrcadRemoteRecord(target, '/r.json', '{}')).rejects.toThrow() - expect(mockExec).toHaveBeenCalledOnce() - mockExec.mockRejectedValueOnce(new Error('exit 1')).mockResolvedValueOnce('') - await expect(writeAtomicOrcadRemoteRecord(target, '/r.json', '{}')).rejects.toThrow() - expect(String(mockExec.mock.calls.at(-1)?.[1])).toContain('rm -f') - }) - - it('refuses Windows hosts, which the orcad lifecycle does not support', async () => { - await expect( - readBoundedOrcadRemoteRecord({ conn, host: windows }, 'C:/r.json', 64) - ).rejects.toThrow() - expect(mockExec).not.toHaveBeenCalled() - }) -}) - -describe('activation record store', () => { - const options = { conn, host: linux, remoteHome: '/home/u' } - const newer = JSON.stringify({ ...emptyOrcadActivationRecord(), schemaVersion: 2 }) - - it('reads a lost read as an error, never as an empty record', async () => { - mockExec.mockRejectedValueOnce(new Error('channel lost')) - await expect(readOrcadActivationRecord(options)).rejects.toThrow('channel lost') - }) - - it('reads a newer schema as unreadable and never overwrites it', async () => { - mockExec.mockResolvedValue(`__ORCAD_RECORD_PRESENT__\n${newer}`) - await expect(readOrcadActivationRecord(options)).rejects.toThrow('schemaVersion 2') - await expect(writeOrcadActivationRecord(options, emptyOrcadActivationRecord())).rejects.toThrow( - 'Refusing to overwrite' - ) - expect(mockExec.mock.calls.some(([, command]) => String(command).includes('mv -f'))).toBe(false) - }) - - it('writes atomically when the host has no record yet', async () => { - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_ABSENT__\n').mockResolvedValueOnce('') - await writeOrcadActivationRecord(options, emptyOrcadActivationRecord()) - expect(String(mockExec.mock.calls[1]?.[1])).toMatch(/printf %s .* && mv -f /s) - }) -}) - -describe('installed build identity and readiness', () => { - const slot = { ...target, remoteInstallDir: '/home/u/.orca-remote/orcad-0.2.0+bb01' } - const expectation = { buildHash: BUILD_HASH, fullVersion: '0.2.0+bb01' } - - it('reads the 16-hex build hash the slot reports', async () => { - mockExec.mockResolvedValueOnce(`noise\n__ORCAD_BUILD_HASH__ ${BUILD_HASH.toUpperCase()}\n`) - await expect(readRemoteOrcadBuildHash(target, '/slot')).resolves.toBe(BUILD_HASH) - mockExec.mockResolvedValueOnce('') - await expect(readRemoteOrcadBuildHash(target, '/slot')).rejects.toThrow() - }) - - it.each([ - ['DEAD', 'exited'], - ['UNKNOWN', 'unverifiable'], - ['', 'unverifiable'] - ])('reports a %j liveness probe as %s', async (liveness, verdict) => { - mockExec.mockResolvedValueOnce(liveness) - await expect(probeActiveOrcadReadiness(slot, expectation)).rejects.toMatchObject({ - verdict - }) - }) - - it('accepts only the expected build once the process is live', async () => { - mockExec.mockResolvedValueOnce('LIVE').mockResolvedValueOnce(`${readyLine()}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).resolves.toMatchObject({ - runtimeId: 'r1' - }) - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine('ffffffffffffffff')}\n`) - const rejected = probeActiveOrcadReadiness(slot, expectation) - await expect(rejected).rejects.toBeInstanceOf(OrcadActiveReadinessError) - await expect(rejected).rejects.toMatchObject({ verdict: 'rejected' }) - }) - - it.each([ - ['a spawn-probed PTY', { coverage: 'pty-spawn' as const }], - [ - 'a handshake on a host whose daemon never spawn-probes', - { coverage: 'handshake' as const, platform: 'win32' } - ], - ['an older build that does not report coverage', { coverage: undefined }] - ])('accepts %s', async (_name, daemon) => { - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, daemon)}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).resolves.toMatchObject({ - runtimeId: 'r1' - }) - }) - - it('rejects handshake-only coverage on a host whose daemon should spawn a PTY', async () => { - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, { coverage: 'handshake' })}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).rejects.toMatchObject({ - verdict: 'rejected', - message: expect.stringContaining("coverage 'handshake'") - }) - }) - - it('applies the same coverage rule to a slot it launches', async () => { - mockExec - .mockResolvedValueOnce('4242') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, { coverage: 'handshake' })}\n`) - await expect( - launchOrcadSlotAndAwaitReadiness( - { ...target, readinessTimeoutMs: 1_000, sleep: async () => {} }, - { - remoteInstallDir: slot.remoteInstallDir, - nodePath: '/usr/bin/node', - fullVersion: '0.2.0+bb01', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777 - }, - expectation - ) - ).rejects.toMatchObject({ verdict: 'rejected' }) - }) -}) - -describe('readiness parsing bounds', () => { - it('waits on a half-written last line but rejects a finished invalid one', () => { - expect(parseOrcadReadinessOutput('{"type":"orca_ser')).toEqual({ state: 'pending' }) - expect(parseOrcadReadinessOutput('{"type":"orca_ser\n')).toMatchObject({ - state: 'malformed' - }) - }) - - it('rejects a payload over the size cap', () => { - expect(parseOrcadReadinessOutput('x'.repeat(256 * 1024 + 1))).toMatchObject({ - state: 'malformed' - }) - }) -}) - -describe('orcad remote context', () => { - it('refuses a Windows host before probing anything else', async () => { - vi.mocked(detectRemoteHostPlatform).mockResolvedValueOnce(windows) - const sshTarget = { id: 't', label: 't', host: 'h', port: 22, username: 'u' } - await expect(resolveOrcadRemoteContext(sshTarget, conn)).rejects.toThrow() - expect(mockExec).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index da4cf968706..33fd877ba41 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -7,10 +7,6 @@ import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' -import { - ORCAD_STOP_REQUEST_FILENAME, - ORCAD_STOP_REQUESTS_CAPABILITY -} from '../../shared/orcad-stop-request' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { assertPosixOrcadHost as assertPosixHost, @@ -19,12 +15,10 @@ import { } from './orcad-remote-host-support' /** - * Ask the orcad recorded in a version dir to stop, and wait for it to go. + * Signal the orcad recorded in a version dir and wait for it to go. * - * A build whose readiness advertises `health.stopRequests` is asked through the slot-local - * request file, which only that orcad watches; older builds keep receiving SIGTERM. Both need - * the readiness PID to corroborate the launcher's PID first, so a reused PID is never stopped. * `justLaunched` is only for this client's fixed exec launcher, including pre-readiness exits. + * Incumbents need their own readiness PID to corroborate the launcher's PID before any signal. */ export function stopOrcadCommand( host: RemoteHostPlatform, @@ -37,34 +31,26 @@ export function stopOrcadCommand( assertPosixHost(host) const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - const requestFile = shellEscape( - joinRemotePath(host, remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME) - ) const readRuntimePid = [ `const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`, `const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`, `if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`, - `const mode = r.health?.stopRequests === ${ORCAD_STOP_REQUESTS_CAPABILITY} ? 'request' : 'signal';`, - `process.stdout.write(String(pid) + ':' + mode);` + `process.stdout.write(String(pid));` ].join(' ') return [ posixProcessAliveShellFunction({ refuseUnverifiable: true }), `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', - 'stop_mode=signal;', // Older launchers recorded a waiting shell, whose exit does not prove runtime exit. ...(options.justLaunched ? [] : [ - `runtime_answer=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + + `runtime_pid=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + `"$orcad_runtime" -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, - '[ "$pid" = "${runtime_answer%%:*}" ] || { echo UNKNOWN; exit 0; };', - 'stop_mode=${runtime_answer#*:};' + '[ "$pid" = "$runtime_pid" ] || { echo UNKNOWN; exit 0; };' ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', - 'if [ "$stop_mode" = request ]; then', - `( umask 077; : > ${requestFile} ) 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };`, - 'else kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; }; fi;', + 'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };', `i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`, 'orcad_alive "$pid" || { echo STOPPED; exit 0; };', 'sleep 1; i=$((i + 1)); done;', diff --git a/src/main/ssh/orcad-remote-record-file.ts b/src/main/ssh/orcad-remote-record-file.ts deleted file mode 100644 index 5c29c3b3ef5..00000000000 --- a/src/main/ssh/orcad-remote-record-file.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Small JSON records Orca keeps on an orcad host (activation, transactions, stop receipts). - * - * Reads are bounded and never swallow a failure: a record that could not be read is not an - * absent one, and treating it as absent is how a client deploys over a live install. - */ -import { randomUUID } from 'node:crypto' -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { removeRemoteFileCommand } from './ssh-remote-commands' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' - -const ABSENT_MARKER = '__ORCAD_RECORD_ABSENT__' -const PRESENT_MARKER = '__ORCAD_RECORD_PRESENT__' - -/** `present` carries raw bytes; schema checks belong to the caller that owns the format. */ -export type OrcadRemoteRecordRead = { state: 'absent' } | { state: 'present'; raw: string } - -export async function readBoundedOrcadRemoteRecord( - target: OrcadRemoteExecTarget, - path: string, - maxBytes: number -): Promise { - assertPosixOrcadHost(target.host) - const file = shellEscape(path) - // Why markers: an empty stdout must never be mistaken for "no record" when the read failed. - const output = await execOrcadRemote( - target, - `if [ ! -e ${file} ] && [ ! -L ${file} ]; then printf '%s\\n' ${ABSENT_MARKER}; exit 0; fi; ` + - `[ -f ${file} ] || exit 65; size=$(wc -c < ${file}) || exit 65; ` + - `[ "$size" -le ${maxBytes} ] || exit 65; ` + - `printf '%s\\n' ${PRESENT_MARKER}; cat ${file}` - ) - const newline = output.indexOf('\n') - const marker = (newline === -1 ? output : output.slice(0, newline)).trim() - if (marker === ABSENT_MARKER) { - return { state: 'absent' } - } - if (marker !== PRESENT_MARKER) { - throw new Error('orcad host record read returned no verifiable answer') - } - return { state: 'present', raw: newline === -1 ? '' : output.slice(newline + 1) } -} - -export async function writeAtomicOrcadRemoteRecord( - target: OrcadRemoteExecTarget, - path: string, - contents: string -): Promise { - assertPosixOrcadHost(target.host) - const partialPath = `${path}.partial.${process.pid}.${randomUUID()}` - try { - await execOrcadRemote( - target, - `umask 077; printf %s ${shellEscape(contents)} > ${shellEscape(partialPath)} && ` + - `mv -f ${shellEscape(partialPath)} ${shellEscape(path)}` - ) - } catch (error) { - // Why keep the partial on an unconfirmed termination: the write may still be running there. - if (!isUnconfirmedSshCommandTermination(error)) { - await execOrcadRemote(target, removeRemoteFileCommand(target.host, partialPath)).catch( - () => {} - ) - } - throw error - } -} diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index 02aada05b2b..4d2f9150a75 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -1,6 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn(), @@ -11,17 +9,9 @@ vi.mock('./ssh-relay-install-transfers', () => ({ writeRelayFile: vi.fn().mockResolvedValue(undefined), uploadRelayDirectory: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn().mockResolvedValue(undefined) -})) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) -})) import { execCommand } from './ssh-relay-deploy-helpers' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' +import { writeRelayFile } from './ssh-relay-install-transfers' import { rollbackOrcad, type OrcadRollbackOptions } from './orcad-remote-rollback' import { emptyOrcadActivationRecord, type OrcadActivationRecord } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -78,60 +68,35 @@ function readyLine(version: string): string { }) } -type HostOverrides = { - restores?: string[] - readinessAtMs?: number - targetReady?: boolean - snapshot?: string - comparison?: string -} - -function scriptHost(log: string[], overrides: HostOverrides = {}): void { - const restores = [...(overrides.restores ?? [])] +function scriptHost( + log: string[], + overrides: { restore?: string; readinessAtMs?: number } = {} +): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) - if (text.includes('__ORCAD_RECORD_PRESENT__')) { - return text.includes('transaction.json') - ? '__ORCAD_RECORD_ABSENT__\n' - : `__ORCAD_RECORD_PRESENT__\n${JSON.stringify(record())}` + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' } - if (text.includes('__ORCAD_BUILD_HASH__')) { - return `__ORCAD_BUILD_HASH__ ${BUILD_HASH}\n` - } - if (text.includes('echo PRESENT')) { - return overrides.snapshot ?? 'PRESENT' - } - if (text.includes('stat -c %Y')) { + if (text.includes('find ') && text.includes('stat')) { return 'UNKNOWN' } if (text.includes('kill -TERM')) { log.push(`stop:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) return 'STOPPED' } - if (text.includes('verdict=UNCHANGED')) { - log.push('compare') - return overrides.comparison ?? 'CHANGED' - } - if (text.includes('tar -C') && text.includes('-cf')) { - log.push('rescue') - return 'CAPTURED' - } if (text.includes('tar -C') && text.includes('-xf')) { - log.push(text.includes('rollback-rescue-') ? 'restore-rescue' : 'restore') - return restores.shift() ?? 'RESTORED' + log.push('restore') + return overrides.restore ?? 'RESTORED' } if (text.includes('nohup')) { log.push(`launch:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) return '9999' } - if (text.startsWith('head -c ') && text.includes('.orcad-readiness')) { + if (text.startsWith('cat ') && text.includes('.orcad-readiness')) { if (overrides.readinessAtMs !== undefined && Date.now() < overrides.readinessAtMs) { return '' } - if (text.includes(ACTIVE)) { - return readyLine(ACTIVE) - } - return overrides.targetReady === false ? '' : readyLine(TARGET) + return readyLine(TARGET) } return '' }) @@ -147,9 +112,8 @@ function options(overrides: Partial = {}): OrcadRollbackOp userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, targetBuildHash: BUILD_HASH, - targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, readinessTimeoutMs: 50, sleep: async () => {}, now: () => new Date('2026-02-02T00:00:00.000Z'), @@ -169,7 +133,7 @@ describe('rollbackOrcad', () => { expect(result).toMatchObject({ outcome: 'rolled-back', target: TARGET }) // Restoring under a running orcad would replace the store beneath a process holding it; // starting first would let the older build migrate the newer build's state. - expect(log).toEqual([`stop:${ACTIVE}`, 'rescue', 'restore', `launch:${TARGET}`]) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) }) it('allows rollback startup time after a slow bundled preflight', async () => { @@ -188,7 +152,7 @@ describe('rollbackOrcad', () => { ) expect(result.outcome).toBe('rolled-back') expect(elapsedMs).toBe(100_000) - expect(log).toEqual([`stop:${ACTIVE}`, 'rescue', 'restore', `launch:${TARGET}`]) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) } finally { clock.mockRestore() } @@ -198,79 +162,57 @@ describe('rollbackOrcad', () => { const log: string[] = [] scriptHost(log) const result = await rollbackOrcad( - options({ census: { liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 3 } }) + options({ census: { liveSessions: 3, startedSinceActivation: 2 } }) ) expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_rollback_orphans_live_terminals' }) expect(log).toEqual([]) - expect(vi.mocked(writeAtomicOrcadRemoteRecord)).not.toHaveBeenCalled() + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() }) it('refuses when the snapshot is gone from the host', async () => { const log: string[] = [] - scriptHost(log, { snapshot: 'ABSENT' }) + mockExec.mockImplementation(async (_conn, command: string) => + String(command).includes('state.tar') ? 'ABSENT' : '' + ) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_rollback_snapshot_missing' }) expect(log).toEqual([]) }) - it('does not read a lost snapshot probe as a missing snapshot', async () => { + it('does not start the old build when the restore failed', async () => { const log: string[] = [] - scriptHost(log, { snapshot: '' }) - const result = await rollbackOrcad(options()) - expect(result).toMatchObject({ - outcome: 'refused', - code: 'orcad_rollback_snapshot_unverifiable' - }) - expect(log).toEqual([]) - }) - - it('puts the rescued state and the newer build back when the restore failed', async () => { - const log: string[] = [] - scriptHost(log, { restores: ['FAILED'] }) + scriptHost(log, { restore: 'FAILED' }) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_rollback_restore_failed' }) - expect(log).toEqual([ - `stop:${ACTIVE}`, - 'rescue', - 'restore', - 'restore-rescue', - `launch:${ACTIVE}` - ]) - expect(result.outcome === 'failed' && result.reason).toContain('is serving again') + expect(log).toEqual([`stop:${ACTIVE}`, 'restore']) + expect(result.outcome === 'failed' && result.reason).toContain('Do NOT start the older build') }) - it('keeps the newer state when a failed target may have changed it and no census is fresh', async () => { + it('leaves the record naming the newer version when the target fails to come up', async () => { const log: string[] = [] - scriptHost(log, { targetReady: false }) + scriptHost(log) + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' + } + if (text.includes('kill -TERM')) { + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-xf')) { + return 'RESTORED' + } + // The target never publishes readiness. + return '' + }) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_activation_no_readiness' }) - expect(result.outcome === 'failed' && result.reason).toContain('fresh host terminal census') - expect(log).toEqual([ - `stop:${ACTIVE}`, - 'rescue', - 'restore', - `launch:${TARGET}`, - `stop:${TARGET}`, - 'compare' - ]) // Until the target is proven serving, `active` must still name the version an operator // would have to bring back. - expect( - vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some((call) => String(call[1]).endsWith('orcad-active.json')) - ).toBe(false) - }) - - it('restores the rescue and the newer build after a failed target when no terminal started', async () => { - const log: string[] = [] - scriptHost(log, { targetReady: false }) - const result = await rollbackOrcad(options({ terminalsStartedSince: async () => 0 })) - expect(result.outcome === 'failed' && result.reason).toContain('is serving again') - expect(log.slice(-3)).toEqual(['compare', 'restore-rescue', `launch:${ACTIVE}`]) + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() }) it('records the rollback only after the target answers healthy', async () => { @@ -278,9 +220,9 @@ describe('rollbackOrcad', () => { scriptHost(log) await rollbackOrcad(options()) const written = vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.find((call) => String(call[1]).endsWith('orcad-active.json')) - expect(JSON.parse(String(written?.[2]))).toMatchObject({ + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ active: TARGET, previous: null, snapshot: null diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 4640a6079bc..412021c2f22 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -8,27 +8,46 @@ * build cannot be shown to read the result. Rollback therefore restores the pre-activation * snapshot, and refuses when restoring it would orphan work (`assessOrcadRollback`). * - * The order is the whole safety argument: stop, rescue, restore, then start. Restoring under - * a running orcad would replace the store beneath a process holding it open, and starting - * before restoring would let the old build migrate the new build's state. The rescue copy of - * the newer state, and the journal under the activation fence, make each step undoable. + * The order below is the whole safety argument: stop, then restore, then start. Restoring + * under a running orcad would replace the store beneath a process holding it open, and + * starting before restoring would let the old build migrate the new build's state — the + * failure this is meant to avoid, arrived at from the other side. */ import type { SshConnection } from './ssh-connection' -import type { OrcadActivationRecord } from './orcad-activation-record' -import type { OrcadTerminalCensus } from './orcad-update-plan' -import type { OrcadActivationVerdict } from './orcad-activation-gate' -import type { OrcadDaemonProtocolFacts } from './orcad-daemon-protocol-crossing' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { sameOrcadActivationRecord } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock -} from './orcad-activation-lock' import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' -import { rollbackOrcadLocked } from './orcad-rollback-transition' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { writeRelayFile } from './ssh-relay-install-transfers' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, + withRolledBackVersion, + type OrcadActivationRecord +} from './orcad-activation-record' +import { assessOrcadRollback, type OrcadTerminalCensus } from './orcad-update-plan' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand +} from './orcad-remote-launch' +import { + newestStateMtimeCommand, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotRestore, + probeOrcadStateSnapshotCommand, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { orcadActivationPath } from './orcad-activation-record-store' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' export type OrcadRollbackOptions = { conn: SshConnection @@ -42,10 +61,6 @@ export type OrcadRollbackOptions = { census: OrcadTerminalCensus /** Expected build hash of the rollback target, from the client's copy of those bytes. */ targetBuildHash: string - /** The rollback target's daemon protocol facts, from the same copy. */ - targetDaemonProtocol: OrcadDaemonProtocolFacts - /** A fresh census for a failed target that changed state; see `orcad-incumbent-recovery.ts`. */ - terminalsStartedSince?: (since: string) => Promise readinessTimeoutMs?: number now?: () => Date sleep?: (ms: number) => Promise @@ -57,34 +72,170 @@ export type OrcadRollbackResult = | { outcome: 'refused'; code: string; reason: string } | { outcome: 'failed'; code: string; reason: string } -export async function rollbackOrcad(input: OrcadRollbackOptions): Promise { - assertPosixOrcadHost(input.host) - const options = { - ...input, - readinessTimeoutMs: resolveOrcadActivationReadinessTimeout( - input.readinessTimeoutMs, - ORCAD_STARTUP_READINESS_TIMEOUT_MS - ) - } - return withOrcadActivationLock(options, async (lock) => { - if (await readOrcadActivationTransaction(options)) { - lock.retain() - return { - outcome: 'refused', - code: 'orcad_activation_recovery_required', - reason: - 'An earlier activation on this host was interrupted. Recover it before rolling back.' - } - } - if (!sameOrcadActivationRecord(await readOrcadActivationRecord(options), options.record)) { - return { - outcome: 'refused', - code: 'orcad_rollback_record_changed', - reason: - 'The host activation record changed while this rollback was waiting. Refresh the ' + - 'host state and review the new rollback target before trying again.' - } - } - return rollbackOrcadLocked(options, lock) +const READINESS_POLL_MS = 500 +const STOP_WAIT_SECONDS = 20 + +function exec(options: OrcadRollbackOptions, command: string): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal }) } + +function snapshotDirPath(options: OrcadRollbackOptions, dirName: string): string { + return joinRemotePath( + options.host, + options.remoteHome, + RELAY_REMOTE_DIR, + ORCAD_STATE_SNAPSHOT_DIR, + dirName + ) +} + +/** Has the store been written since activation? `null` when it cannot be established. */ +async function readStateWritesSinceActivation( + options: OrcadRollbackOptions +): Promise { + if (!options.record.activatedAt) { + return null + } + const activatedAtSeconds = Math.floor(Date.parse(options.record.activatedAt) / 1000) + if (!Number.isFinite(activatedAtSeconds)) { + return null + } + const newest = parseNewestStateMtimeSeconds( + await exec(options, newestStateMtimeCommand(options.host, options.userDataDir)).catch(() => '') + ) + return newest === null ? null : newest >= activatedAtSeconds +} + +export async function rollbackOrcad(options: OrcadRollbackOptions): Promise { + const now = options.now ?? ((): Date => new Date()) + const snapshotPresent = options.record.snapshot + ? ( + await exec( + options, + probeOrcadStateSnapshotCommand( + options.host, + snapshotDirPath(options, options.record.snapshot.dirName) + ) + ).catch(() => 'ABSENT') + ).trim() === 'PRESENT' + : false + + const safety = assessOrcadRollback({ + record: options.record, + snapshotPresent, + census: options.census, + stateWritesSinceActivation: await readStateWritesSinceActivation(options) + }) + if (safety.safety === 'unsafe') { + return { outcome: 'refused', code: safety.code, reason: safety.reason } + } + + if (options.record.active) { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + options.record.active + ) + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, outgoingDir, { + waitSeconds: STOP_WAIT_SECONDS, + nodePath: options.nodePath + }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'failed', + code: 'orcad_rollback_stop_incomplete', + reason: + `Could not verify that orcad ${options.record.active} exited (${stopped}). ` + + 'Nothing was restored. Orca requires matching runtime readiness before signaling ' + + 'an incumbent and confirmed exit before replacing its state.' + } + } + } + + // Why between stop and start: the store must be replaced while no orcad holds it, and + // before the older build gets a chance to migrate the newer build's state. + const restored = parseOrcadSnapshotRestore( + await exec( + options, + restoreOrcadStateSnapshotCommand( + options.host, + options.userDataDir, + // Guarded by `assessOrcadRollback`: `unsafe` covers a missing snapshot. + snapshotDirPath(options, options.record.snapshot?.dirName ?? '') + ) + ).catch(() => 'FAILED') + ) + if (restored !== 'restored') { + return { + outcome: 'failed', + code: 'orcad_rollback_restore_failed', + reason: + `The pre-activation snapshot could not be restored (${restored}). orcad is stopped and ` + + 'the data root may be partially replaced. Do NOT start the older build against it; ' + + `re-deploy ${options.record.active ?? 'the newer version'}, which can read what is there.` + } + } + + const targetDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, safety.target) + await exec( + options, + orcadLaunchCommand(options.host, { + remoteInstallDir: targetDir, + nodePath: options.nodePath, + fullVersion: safety.target, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port + }) + ) + const deadline = Date.now() + (options.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let parsed = parseOrcadReadinessOutput('') + while (Date.now() < deadline && parsed.state === 'pending') { + options.signal?.throwIfAborted() + parsed = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, targetDir)) + ) + if (parsed.state === 'pending') { + await sleep(READINESS_POLL_MS) + } + } + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: options.targetBuildHash, + fullVersion: safety.target + }) + if (verdict.decision === 'reject') { + return { + outcome: 'failed', + code: verdict.code, + reason: + `The rollback target ${safety.target} did not come up healthy: ${verdict.reason} The ` + + `store has been restored to its pre-activation state. Its stderr is at ` + + `${joinRemotePath(options.host, targetDir, ORCAD_LOG_FILENAME)}.` + } + } + + // Why the record is written last: until the target is proven serving, `active` still names + // the version an operator would need to bring back, and `previous` still names this target. + await writeRelayFile( + options.conn, + options.host, + orcadActivationPath(options.host, options.remoteHome), + serializeOrcadActivationRecord(withRolledBackVersion(options.record, now())), + { signal: options.signal } + ) + return { + outcome: 'rolled-back', + target: safety.target, + discarded: safety.safety === 'lossy' ? safety.discards : [], + verdict + } +} diff --git a/src/main/ssh/orcad-remote-runtime-control.ts b/src/main/ssh/orcad-remote-runtime-control.ts deleted file mode 100644 index e3ddc1566bf..00000000000 --- a/src/main/ssh/orcad-remote-runtime-control.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** Launch a slot and poll its readiness file: the one loop deploy, rollback and recovery share. */ -import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' -import { - orcadLaunchCommand, - parseOrcadReadinessOutput, - readOrcadReadinessCommand, - type OrcadLaunchSpec, - type OrcadReadinessParse -} from './orcad-remote-launch' -import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import type { RemoteHostPlatform } from './ssh-remote-platform' - -const READINESS_POLL_MS = 500 - -export type OrcadRemoteExecTarget = { - conn: SshConnection - host: RemoteHostPlatform - signal?: AbortSignal -} - -export function execOrcadRemote( - target: OrcadRemoteExecTarget, - command: string, - signal = target.signal -): Promise { - return execCommand(target.conn, command, { - wrapCommand: target.host.commandDialect !== 'powershell', - signal - }) -} - -/** Recovery paths must finish even when the request that started them was cancelled. */ -export function withoutAbortSignal( - options: T -): Omit { - const { signal: _signal, ...rest } = options - return rest -} - -export async function launchOrcadAndAwaitReadiness( - target: OrcadRemoteExecTarget & { - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise - }, - spec: OrcadLaunchSpec -): Promise { - await execOrcadRemote(target, orcadLaunchCommand(target.host, spec)) - const deadline = Date.now() + (target.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) - const sleep = target.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) - let last = parseOrcadReadinessOutput('') - while (Date.now() < deadline) { - target.signal?.throwIfAborted() - last = parseOrcadReadinessOutput( - await execOrcadRemote(target, readOrcadReadinessCommand(target.host, spec.remoteInstallDir)) - ) - if (last.state !== 'pending') { - return last - } - await sleep(READINESS_POLL_MS) - } - return last -} diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index 0b0cab2a7a8..4839fd7b2c2 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -49,7 +49,6 @@ import { restoreOrcadStateSnapshotCommand } from './orcad-state-snapshot' import { getRemoteHostPlatform } from './ssh-remote-platform' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' const host = getRemoteHostPlatform('linux-x64') let root = '' @@ -87,39 +86,20 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) -async function launchTestRuntime( - legacyWrapper = false, - stopRequests = false -): Promise<{ +async function launchTestRuntime(legacyWrapper = false): Promise<{ runtimePid: number recordedPid: number terminatedFile: string }> { const terminatedFile = join(versionDir, 'terminated') - const requestFile = join(versionDir, ORCAD_STOP_REQUEST_FILENAME) writeFileSync( join(versionDir, 'orcad.js'), [ - `const fs = require('node:fs');`, `process.on('SIGTERM', () => {`, - ` fs.writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, + ` require('node:fs').writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, ` process.exit(0);`, `});`, - ...(stopRequests - ? [ - // Like orcad's listener: consume the slot request, then stop. - `setInterval(() => {`, - ` if (fs.existsSync(${JSON.stringify(requestFile)})) {`, - ` fs.unlinkSync(${JSON.stringify(requestFile)});`, - ` fs.writeFileSync(${JSON.stringify(terminatedFile)}, 'requested');`, - ` process.exit(0);`, - ` }`, - `}, 20);` - ] - : []), - `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid${ - stopRequests ? ', stopRequests: 1' : '' - }}}));`, + `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid}}));`, `setTimeout(() => process.exit(1), 10_000);` ].join('\n') ) @@ -358,21 +338,6 @@ describe('liveness and stop commands, run for real', () => { expect(stopTestRuntime()).toBe('already-exited') }) - it('stops a build that consumes stop requests by request file, not by signal', async () => { - const { terminatedFile } = await launchTestRuntime(false, true) - expect(stopTestRuntime()).toBe('stopped') - expect(readFileSync(terminatedFile, 'utf8')).toBe('requested') - expect(existsSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) - }) - - it('clears a stop request the previous process never consumed before launching', async () => { - writeFileSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME), '') - const { runtimePid } = await launchTestRuntime(false, true) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') - expect(runtimePid).toBeGreaterThan(1) - }) - it('refuses a legacy wrapper PID both before and after its shell exits', async () => { const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime(true) expect(recordedPid).not.toBe(runtimePid) diff --git a/src/main/ssh/orcad-remote-stop.ts b/src/main/ssh/orcad-remote-stop.ts deleted file mode 100644 index dcfbe314314..00000000000 --- a/src/main/ssh/orcad-remote-stop.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Decommissioning a managed orcad: stop its active instance and record that nothing serves. - * - * Runs under the activation fence and journal, so an interrupted decommission recovers like an - * activation does. It refuses while the terminal census says terminals are live or cannot be - * counted, and it never signals: the instance-bound request reaches only the orcad it names. - * Only proven exit deactivates the record; anything less withdraws the request or keeps the - * fence. POSIX hosts only, like the rest of the orcad deploy core. - */ -import { randomUUID } from 'node:crypto' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { sameOrcadActivationRecord } from './orcad-activation-transaction' -import { - readOrcadActivationTransaction, - writeOrcadActivationTransaction -} from './orcad-activation-transaction-store' -import { withOrcadActivationLock } from './orcad-activation-lock' -import { - createOrcadDecommissionTransaction, - withOrcadDecommissionProcessExited, - withOrcadDecommissionStopDispatched -} from './orcad-decommission-transaction' -import { readRemoteOrcadManagedStopTarget } from './orcad-managed-remote-stop' -import { settleOrcadDecommissionStop } from './orcad-decommission-stop' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import type { OrcadSlotOptions } from './orcad-recovery-slot' -import type { OrcadDecommissionResult } from '../../shared/orcad-decommission' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' - -export type OrcadDecommissionOptions = OrcadSlotOptions & { - /** The record the caller reviewed; a changed host record refuses rather than guessing. */ - record: OrcadActivationRecord - /** Taken by the caller just before; any live or uncounted terminal refuses. */ - census: OrcadTerminalCensus - now?: () => Date -} - -type Refusal = Extract - -function refuse(verdict: Refusal['verdict'], code: string, reason: string): Refusal { - return { outcome: 'refused', verdict, code, reason } -} - -function censusRefusal(census: OrcadTerminalCensus): Refusal | null { - if (census.liveSessions === null) { - return refuse( - 'unverifiable', - 'orcad_decommission_census_unavailable', - 'The terminal daemon did not answer a session count, so decommissioning could end ' + - 'running work. Retry when the host answers.' - ) - } - if (census.liveSessions > 0) { - return refuse( - 'live', - 'orcad_decommission_terminals_running', - `${census.liveSessions} terminal${census.liveSessions === 1 ? ' is' : 's are'} still ` + - 'running on this host. Close them before decommissioning the server.' - ) - } - return null -} - -export async function decommissionRemoteOrcad( - options: OrcadDecommissionOptions -): Promise { - assertPosixOrcadHost(options.host) - const now = options.now ?? ((): Date => new Date()) - return withOrcadActivationLock(options, async (lock) => { - if (await readOrcadActivationTransaction(options)) { - lock.retain() - return refuse( - 'unverifiable', - 'orcad_activation_recovery_required', - 'An earlier activation on this host was interrupted. Recover it before decommissioning.' - ) - } - const record = await readOrcadActivationRecord(options) - if (!sameOrcadActivationRecord(record, options.record)) { - return refuse( - 'unverifiable', - 'orcad_decommission_record_changed', - 'The host activation record changed since it was reviewed. Refresh and try again.' - ) - } - const activeVersion = record.active - if (!activeVersion) { - return refuse( - 'unverifiable', - 'orcad_decommission_nothing_active', - 'No orcad version is active on this host, so there is nothing to decommission.' - ) - } - const census = censusRefusal(options.census) - if (census) { - return census - } - const target = await readRemoteOrcadManagedStopTarget(options, activeVersion) - if (target.state === 'refused') { - return refuse(target.verdict, target.code, target.reason) - } - - let transaction = createOrcadDecommissionTransaction({ - transactionId: randomUUID(), - recordBefore: { ...record, active: activeVersion }, - now: now() - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - const request = { - schemaVersion: 1 as const, - transactionId: transaction.transactionId, - ...target.context, - // Best effort: an idle daemon goes with orcad, a busy one keeps its terminals. - retireIdleDaemon: true as const - } - // Durable before it can reach the host, so recovery can settle exactly this request. - transaction = withOrcadDecommissionStopDispatched(transaction, request, now()) - await writeOrcadActivationTransaction(options, transaction) - - const settlement = await settleOrcadDecommissionStop(options, request) - if (settlement.state === 'withdrawn') { - // orcad never acted on it and keeps serving; the record never changed. - return refuse(settlement.verdict, 'orcad_decommission_stop_withdrawn', settlement.reason) - } - if (settlement.state === 'unsettled') { - lock.retain() - return refuse(settlement.verdict, 'orcad_decommission_stop_unsettled', settlement.reason) - } - transaction = withOrcadDecommissionProcessExited(transaction, now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, transaction.recordAfter) - return { - outcome: 'decommissioned', - version: activeVersion, - retirement: settlement.retirement - } - }) -} diff --git a/src/main/ssh/orcad-rollback-transition.ts b/src/main/ssh/orcad-rollback-transition.ts deleted file mode 100644 index ce5fe39f34b..00000000000 --- a/src/main/ssh/orcad-rollback-transition.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** The locked, journaled half of `rollbackOrcad`. */ -import { randomUUID } from 'node:crypto' -import type { OrcadRollbackOptions, OrcadRollbackResult } from './orcad-remote-rollback' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { withRolledBackVersion } from './orcad-activation-record' -import { writeOrcadActivationRecord } from './orcad-activation-record-store' -import { evaluateOrcadActivation } from './orcad-activation-gate' -import { assessOrcadRollback } from './orcad-update-plan' -import { ORCAD_LOG_FILENAME, type OrcadReadinessParse } from './orcad-remote-launch' -import { - captureOrcadStateSnapshotCommand, - newestStateMtimeCommand, - orcadRollbackRescueDirName, - parseNewestStateMtimeSeconds, - parseOrcadSnapshotCapture, - parseOrcadSnapshotPresence, - parseOrcadSnapshotRestore, - probeOrcadStateSnapshotCommand, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { orcadStopFreedTheHost } from './orcad-remote-process-control' -import { joinRemotePath } from './ssh-remote-platform' -import type { OrcadActivationLockControl } from './orcad-activation-lock' -import type { OrcadRollbackTransaction } from './orcad-activation-transaction' -import { - createOrcadRollbackTransaction, - withOrcadRollbackPhase, - withOrcadRollbackRescue -} from './orcad-activation-transaction-transitions' -import { writeOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - withoutAbortSignal -} from './orcad-remote-runtime-control' -import { - orcadSlotDir, - resolveOrcadSlotIdentity, - stopOrcadSlot, - ORCAD_SLOT_STOP_WAIT_SECONDS, - type OrcadSlotIdentity -} from './orcad-recovery-slot' -import { orcadSnapshotPath, recoverOrcadIncumbent } from './orcad-incumbent-recovery' - -/** A confirmed failure reads as no answer; an unconfirmed one propagates and keeps the fence. */ -async function execOrEmpty(options: OrcadRollbackOptions, command: string): Promise { - return execOrcadRemote(options, command).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) -} - -async function stateWritesSinceActivation(options: OrcadRollbackOptions): Promise { - const activatedAtSeconds = Math.floor(Date.parse(options.record.activatedAt ?? '') / 1000) - if (!Number.isFinite(activatedAtSeconds)) { - return null - } - const newest = parseNewestStateMtimeSeconds( - await execOrEmpty(options, newestStateMtimeCommand(options.host, options.userDataDir)) - ) - return newest === null ? null : newest >= activatedAtSeconds -} - -export async function rollbackOrcadLocked( - options: OrcadRollbackOptions, - lock: OrcadActivationLockControl -): Promise { - const now = options.now ?? ((): Date => new Date()) - const snapshot = options.record.snapshot - const presence = snapshot - ? parseOrcadSnapshotPresence( - await execOrEmpty( - options, - probeOrcadStateSnapshotCommand(options.host, orcadSnapshotPath(options, snapshot.dirName)) - ) - ) - : 'absent' - const safety = assessOrcadRollback({ - record: options.record, - snapshotPresent: presence === 'unverifiable' ? null : presence === 'present', - census: options.census, - targetDaemonProtocol: options.targetDaemonProtocol, - stateWritesSinceActivation: await stateWritesSinceActivation(options) - }) - if (safety.safety === 'unsafe') { - return { outcome: 'refused', code: safety.code, reason: safety.reason } - } - if (!snapshot || !options.record.active) { - return { - outcome: 'refused', - code: 'orcad_rollback_no_active', - reason: 'No orcad version is active on this host, so there is nothing to roll back from.' - } - } - let incumbent: OrcadSlotIdentity - try { - incumbent = await resolveOrcadSlotIdentity(options, options.record.active) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { - outcome: 'refused', - code: 'orcad_rollback_active_identity_unverifiable', - reason: `The active orcad identity could not be verified: ${errorMessage(error)} Nothing was changed.` - } - } - - const startedAt = now() - let transaction: OrcadRollbackTransaction = createOrcadRollbackTransaction({ - transactionId: randomUUID(), - incumbentVersion: incumbent.version, - targetVersion: safety.target, - recordBefore: options.record, - recordAfter: withRolledBackVersion(options.record, startedAt), - rescueDirName: orcadRollbackRescueDirName(incumbent.version, startedAt.getTime()), - now: startedAt - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - - const stopped = await stopOrcadSlot(options, incumbent.remoteDir, false) - if (!orcadStopFreedTheHost(stopped)) { - if (stopped === 'still-running') { - lock.retain() - } - return { - outcome: 'failed', - code: 'orcad_rollback_stop_incomplete', - reason: - `Could not verify that orcad ${incumbent.version} exited within ` + - `${ORCAD_SLOT_STOP_WAIT_SECONDS}s (${stopped}). Nothing was restored. Orca requires ` + - 'matching runtime readiness before signaling an incumbent and confirmed exit before ' + - 'replacing its state.' - } - } - transaction = withOrcadRollbackPhase(transaction, 'incumbent-stopped', now()) - await writeOrcadActivationTransaction(options, transaction) - - const rescue = parseOrcadSnapshotCapture( - await execOrEmpty( - options, - captureOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, transaction.rescue.dirName) - ) - ) - ) - if (rescue === 'failed') { - const recovered = await putIncumbentBack(options, lock, transaction, incumbent, null) - return { - outcome: 'failed', - code: 'orcad_rollback_rescue_snapshot_failed', - reason: - 'The current state could not be preserved in a rescue snapshot, so the data root was ' + - `not replaced. ${recovered}` - } - } - transaction = withOrcadRollbackRescue(transaction, rescue, now()) - await writeOrcadActivationTransaction(options, transaction) - - // Why between stop and start: the older build must never load the newer build's state. - const restored = parseOrcadSnapshotRestore( - await execOrEmpty( - options, - restoreOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, snapshot.dirName) - ) - ) - ) - if (restored !== 'restored') { - const recovered = await putIncumbentBack(options, lock, transaction, incumbent, null) - return { - outcome: 'failed', - code: 'orcad_rollback_restore_failed', - reason: `The pre-activation snapshot could not be restored (${restored}). ${recovered}` - } - } - transaction = withOrcadRollbackPhase(transaction, 'rollback-state-restored', now()) - await writeOrcadActivationTransaction(options, transaction) - - const targetDir = orcadSlotDir(options, safety.target) - let parsed: OrcadReadinessParse | null = null - let launchError: unknown - try { - parsed = await launchOrcadAndAwaitReadiness(options, { - remoteInstallDir: targetDir, - nodePath: options.nodePath, - fullVersion: safety.target, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - launchError = error - } - const verdict = evaluateOrcadActivation(parsed?.state === 'ready' ? parsed.readiness : null, { - buildHash: options.targetBuildHash, - fullVersion: safety.target - }) - if (verdict.decision === 'reject') { - const reason = - launchError === undefined - ? verdict.reason - : `It failed while starting: ${errorMessage(launchError)}` - const targetStop = await stopOrcadSlot(withoutAbortSignal(options), targetDir, true).catch( - (error: unknown) => `unverifiable: ${errorMessage(error)}` - ) - const recovered = - targetStop === 'stopped' || targetStop === 'already-exited' - ? await putIncumbentBack(options, lock, transaction, incumbent, safety.target) - : retainedAfterTargetStop(lock, targetStop) - return { - outcome: 'failed', - code: launchError === undefined ? verdict.code : 'orcad_rollback_target_launch_failed', - reason: - `The rollback target ${safety.target} did not come up healthy: ${reason} ${recovered} ` + - `Its stderr is at ${joinRemotePath(options.host, targetDir, ORCAD_LOG_FILENAME)}.` - } - } - - // The record is written last: until the target is proven serving, `active` still names the - // version an operator would need to bring back. - transaction = withOrcadRollbackPhase(transaction, 'target-ready', now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, transaction.recordAfter) - return { - outcome: 'rolled-back', - target: safety.target, - discarded: safety.safety === 'lossy' ? safety.discards : [], - verdict - } -} - -function retainedAfterTargetStop(lock: OrcadActivationLockControl, stopped: string): string { - lock.retain() - return ( - `The target could not be confirmed stopped (${stopped}), so the rescue snapshot was not ` + - 'restored over state it may still own. This host requires recovery.' - ) -} - -/** Restores the rescued state when it was replaced, then restarts the incumbent. */ -async function putIncumbentBack( - options: OrcadRollbackOptions, - lock: OrcadActivationLockControl, - transaction: OrcadRollbackTransaction, - incumbent: OrcadSlotIdentity, - launchedVersion: string | null -): Promise { - try { - const recovery = await recoverOrcadIncumbent(withoutAbortSignal(options), { - transactionStartedAt: transaction.startedAt, - launchedVersion, - incumbent, - restoreState: transaction.rescue.state === 'pending' ? null : transaction.rescue, - slotsProvenExited: true - }) - if (recovery.outcome === 'refused') { - lock.retain() - return recovery.reason - } - lock.recovered() - return `orcad ${incumbent.version} was restored and is serving again.` - } catch (error) { - lock.retain() - return `Restoring orcad ${incumbent.version} failed: ${errorMessage(error)} This host requires recovery.` - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-runtime-decommission.test.ts b/src/main/ssh/orcad-runtime-decommission.test.ts deleted file mode 100644 index b045fbcefdc..00000000000 --- a/src/main/ssh/orcad-runtime-decommission.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { getRuntimeEnvironmentSidecarPath } from '../../shared/runtime-environment-sidecar' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { - createManagedLifecycleHarness, - MANAGED_VERSION -} from './orcad-managed-lifecycle-test-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown; transaction: unknown } = { store: null, transaction: null } - return { - state, - resolveContext: vi.fn(), - census: vi.fn(), - decommission: vi.fn(), - recover: vi.fn(), - cancel: vi.fn(), - keepServing: vi.fn(), - closeTunnel: vi.fn(), - ensureTunnel: vi.fn(), - retire: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: async () => ({}) }), - getSshTargetRegistryStore: () => mocks.state.store -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-terminal-census-client', () => ({ collectManagedTerminalCensus: mocks.census })) -vi.mock('./orcad-remote-stop', () => ({ decommissionRemoteOrcad: mocks.decommission })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: async () => mocks.state.transaction -})) -vi.mock('./orcad-activation-lock', () => ({ - withStaleOrcadActivationRecoveryLock: async ( - _options: unknown, - run: (lock: { retain: () => void }) => Promise - ) => run({ retain: () => {} }) -})) -vi.mock('./orcad-managed-remote-stop', () => ({ cancelRemoteOrcadManagedStop: mocks.cancel })) -vi.mock('./orcad-decommission-recovery', () => ({ reconcileOrcadDecommission: mocks.keepServing })) -vi.mock('./orcad-managed-tunnel', () => ({ - closeOrcadManagedTunnel: mocks.closeTunnel, - ensureOrcadManagedTunnel: mocks.ensureTunnel -})) - -const { cancelManagedOrcadStop, stopManagedOrcadEnvironment } = - await import('./orcad-runtime-lifecycle') - -const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 } -let harness: ReturnType - -const stop = (isActive = false) => - stopManagedOrcadEnvironment( - harness.userDataPath, - { selector: 'Managed' }, - { isActiveEnvironment: () => isActive, retireLocalState: mocks.retire } - ) - -function expectStillLinked(): void { - expect(listEnvironments(harness.userDataPath)[0]?.orcadDeployment).toBeDefined() - expect(getManagedOrcadOwnerEnvironmentId(harness.current().owner)).toBe('environment-1') - expect(mocks.retire).not.toHaveBeenCalled() - expect(mocks.closeTunnel).not.toHaveBeenCalled() -} - -beforeEach(() => { - vi.resetAllMocks() - harness = createManagedLifecycleHarness() - mocks.state.store = harness.targetStore - mocks.state.transaction = null - mocks.resolveContext.mockImplementation(async () => harness.context()) - mocks.census.mockResolvedValue(idle) -}) - -afterEach(() => rmSync(harness.userDataPath, { recursive: true, force: true })) - -describe('stopManagedOrcadEnvironment', () => { - it('unlinks the server only after the host proves orcad exited', async () => { - mocks.decommission.mockResolvedValueOnce({ - outcome: 'decommissioned', - version: MANAGED_VERSION, - retirement: 'retired' - }) - await expect(stop()).resolves.toEqual({ - outcome: 'unlinked', - verdict: 'exited', - environmentId: 'environment-1', - sshTargetId: 'ssh-1', - stoppedVersion: MANAGED_VERSION, - retirement: 'retired' - }) - expect(mocks.decommission.mock.calls[0]?.[0]).toMatchObject({ census: idle, port: 6_768 }) - expect(listEnvironments(harness.userDataPath)).toEqual([]) - expect( - readFileSync(getRuntimeEnvironmentSidecarPath(harness.userDataPath), 'utf8') - ).not.toContain('orcadDeployment') - expect(harness.current().owner).toBeUndefined() - expect(harness.flushes).toHaveLength(1) - expect(mocks.retire).toHaveBeenCalledWith('environment-1') - expect(mocks.closeTunnel).toHaveBeenCalledWith('environment-1') - }) - - it.each([ - ['live', 'orcad_decommission_terminals_running'], - ['unverifiable', 'orcad_decommission_census_unavailable'], - ['unverifiable', 'orcad_decommission_stop_unsettled'] - ] as const)('keeps every link when the verdict is %s (%s)', async (verdict, code) => { - mocks.decommission.mockResolvedValueOnce({ outcome: 'refused', verdict, code, reason: 'no' }) - await expect(stop()).resolves.toMatchObject({ outcome: 'refused', verdict, code }) - expectStillLinked() - expect(existsSync(getRuntimeEnvironmentSidecarPath(harness.userDataPath))).toBe(true) - }) - - it('refuses the Active Server before contacting the host', async () => { - await expect(stop(true)).resolves.toMatchObject({ code: 'orcad_stop_active_environment' }) - expect(mocks.resolveContext).not.toHaveBeenCalled() - expectStillLinked() - }) - - it('finishes an interrupted stop from its journal and unlinks only on proven exit', async () => { - mocks.state.transaction = { operation: 'decommission', activeVersion: MANAGED_VERSION } - mocks.recover.mockResolvedValueOnce({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_decommission_unsettled', - reason: 'no answer' - }) - await expect(stop()).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expectStillLinked() - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'committed', - activeVersion: null, - readiness: null - }) - await expect(stop()).resolves.toMatchObject({ outcome: 'unlinked', verdict: 'exited' }) - expect(mocks.decommission).not.toHaveBeenCalled() - }) - - it('refuses while another interrupted operation holds the journal', async () => { - mocks.state.transaction = { operation: 'activate' } - await expect(stop()).resolves.toMatchObject({ code: 'orcad_activation_recovery_required' }) - expectStillLinked() - }) - - it('unlinks a server whose record already shows nothing active', async () => { - mocks.resolveContext.mockImplementation(async () => harness.context({ active: null })) - await expect(stop()).resolves.toMatchObject({ outcome: 'unlinked', stoppedVersion: null }) - expect(mocks.decommission).not.toHaveBeenCalled() - }) -}) - -describe('cancelManagedOrcadStop', () => { - const request = { transactionId: 't-1', version: MANAGED_VERSION } - const cancel = () => cancelManagedOrcadStop(harness.userDataPath, { selector: 'Managed' }) - - it('has nothing to cancel without a decommission journal', async () => { - await expect(cancel()).resolves.toEqual({ outcome: 'none' }) - expect(mocks.cancel).not.toHaveBeenCalled() - }) - - it('withdraws a stop orcad never acted on and keeps the server serving', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: MANAGED_VERSION, - request - } - mocks.cancel.mockResolvedValueOnce({ outcome: 'canceled' }) - mocks.keepServing.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: MANAGED_VERSION, - readiness: null - }) - await expect(cancel()).resolves.toEqual({ outcome: 'canceled', activeVersion: MANAGED_VERSION }) - expect(mocks.keepServing.mock.calls[0]?.[1]).toEqual({ - action: 'keep-serving', - version: MANAGED_VERSION - }) - expect(mocks.ensureTunnel).toHaveBeenCalledOnce() - expectStillLinked() - }) - - it('refuses once orcad already acted on the stop', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: MANAGED_VERSION, - request - } - mocks.cancel.mockResolvedValueOnce({ outcome: 'dispatched' }) - await expect(cancel()).resolves.toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_stop_already_dispatched' - }) - expect(mocks.keepServing).not.toHaveBeenCalled() - }) - - it('reports an already-exited stop for stop to finish', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'process-exited', - activeVersion: MANAGED_VERSION, - request - } - await expect(cancel()).resolves.toEqual({ outcome: 'already-stopped' }) - expectStillLinked() - }) -}) diff --git a/src/main/ssh/orcad-runtime-decommission.ts b/src/main/ssh/orcad-runtime-decommission.ts deleted file mode 100644 index 4eb9f335fa0..00000000000 --- a/src/main/ssh/orcad-runtime-decommission.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Stopping a managed orcad and unlinking it, on T6-4's journaled decommission. The server stays - * linked — its SSH claim, tunnel and deployment record — unless the host proves orcad exited. - */ -import type { - OrcadManagedCancelStopResult, - OrcadManagedStopResult -} from '../../shared/orcad-managed-runtime' -import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { - KnownRuntimeEnvironment, - OrcadDeploymentLink -} from '../../shared/runtime-environments' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { withStaleOrcadActivationRecoveryLock } from './orcad-activation-lock' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { reconcileOrcadDecommission } from './orcad-decommission-recovery' -import { cancelRemoteOrcadManagedStop } from './orcad-managed-remote-stop' -import { - managedOrcadSlot, - requireManagedOrcadInfrastructure, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' -import { closeOrcadManagedTunnel, ensureOrcadManagedTunnel } from './orcad-managed-tunnel' -import { clearManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals' -import { decommissionRemoteOrcad } from './orcad-remote-stop' -import { withManagedOrcadLifecycle } from './orcad-runtime-maintenance' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' -import { RemoteInstallLockBusyError } from './ssh-relay-install-lock' - -export type ManagedOrcadStopPolicy = { - isActiveEnvironment: (environmentId: string) => boolean - /** Invalidates the server's transport and retires its client-side state once it is unlinked. */ - retireLocalState: (environmentId: string) => Promise | void -} - -type Refusal = Extract -type Stopped = { version: string | null; retirement: 'retired' | 'live' | 'unverifiable' | null } - -function refuse(verdict: Refusal['verdict'], code: string, reason: string): Refusal { - return { outcome: 'refused', verdict, code, reason } -} - -export function stopManagedOrcadEnvironment( - userDataPath: string, - args: { selector: string; signal?: AbortSignal }, - policy: ManagedOrcadStopPolicy -): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => { - const { environment, deployment } = managed - if (policy.isActiveEnvironment(environment.id)) { - return refuse( - 'live', - 'orcad_stop_active_environment', - 'Choose another Active Server in Advanced before stopping this server.' - ) - } - const stopped = await stopRemote(userDataPath, environment, deployment, args.signal) - if ('outcome' in stopped) { - return stopped - } - await unlinkStoppedEnvironment(userDataPath, environment, deployment, policy) - return { - outcome: 'unlinked', - verdict: 'exited', - environmentId: environment.id, - sshTargetId: deployment.sshTargetId, - stoppedVersion: stopped.version, - retirement: stopped.retirement - } - }) -} - -/** Proven exit, or the refusal that keeps the server linked. */ -async function stopRemote( - userDataPath: string, - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - signal?: AbortSignal -): Promise { - const context = await resolveLinkedOrcadContext(environment, deployment, signal) - const slot = managedOrcadSlot(context, deployment.remotePort, signal) - const transaction = await readOrcadActivationTransaction(slot) - if (transaction?.operation === 'decommission') { - // An earlier stop was interrupted: its journal decides, so finish exactly that one. - const recovered = await recoverInterruptedOrcadActivation(slot) - if (recovered.outcome === 'recovered' && recovered.activeVersion === null) { - return { version: transaction.activeVersion, retirement: null } - } - if (recovered.outcome === 'refused') { - return refuse(recovered.verdict, recovered.code, recovered.reason) - } - if (recovered.outcome === 'pending') { - return refuse('unverifiable', recovered.code, recovered.reason) - } - return refuse( - 'live', - 'orcad_stop_withdrawn', - 'The interrupted stop was withdrawn and the server keeps serving. Stop it again.' - ) - } - if (transaction) { - return refuse( - 'unverifiable', - 'orcad_activation_recovery_required', - 'An earlier update on this server was interrupted. Recover it before stopping.' - ) - } - const record = context.activationRecord - if (!record.active) { - // Only a proven exit (or a deploy that never activated) leaves the record without a version. - return { version: null, retirement: null } - } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - const result = await decommissionRemoteOrcad({ ...slot, record, census }) - if (result.outcome === 'refused') { - return refuse(result.verdict, result.code, result.reason) - } - return { version: result.version, retirement: result.retirement } -} - -async function unlinkStoppedEnvironment( - userDataPath: string, - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - policy: ManagedOrcadStopPolicy -): Promise { - // Environment first: a crash before the claim is released leaves a hidden target to resume, - // never a linked server whose SSH target was already handed back. - removeManagedOrcadEnvironment(userDataPath, environment.id) - await policy.retireLocalState(environment.id) - await closeOrcadManagedTunnel(environment.id) - const { claims } = requireManagedOrcadInfrastructure() - claims.release(deployment.sshTargetId, environment.id) - clearManagedOrcadUpdateDeferral(environment.id) - await claims.flush() -} - -/** Withdraws a stop orcad has not acted on yet; the server then keeps serving. */ -export function cancelManagedOrcadStop( - userDataPath: string, - args: { selector: string; signal?: AbortSignal } -): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => { - const { environment, deployment } = managed - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - let result: OrcadManagedCancelStopResult - try { - result = await withStaleOrcadActivationRecoveryLock(slot, async (lock) => { - const transaction = await readOrcadActivationTransaction(slot) - if (transaction?.operation !== 'decommission') { - if (transaction) { - lock.retain() - } - return { outcome: 'none' } - } - if (transaction.phase === 'process-exited') { - lock.retain() - return { outcome: 'already-stopped' } - } - if (transaction.request) { - const cancellation = await cancelRemoteOrcadManagedStop(slot, transaction.request) - if (cancellation.outcome !== 'canceled') { - lock.retain() - return { - outcome: 'refused', - verdict: 'live', - code: 'orcad_stop_already_dispatched', - reason: 'orcad already acted on the stop and is shutting down; it cannot be canceled.' - } - } - } - const kept = await reconcileOrcadDecommission(slot, { - action: 'keep-serving', - version: transaction.activeVersion - }) - if (kept.outcome !== 'recovered' || !kept.activeVersion) { - lock.retain() - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_stop_cancel_unverifiable', - reason: 'The stop was withdrawn but the server could not be shown to be serving.' - } - } - return { outcome: 'canceled', activeVersion: kept.activeVersion } - }) - } catch (error) { - if (error instanceof RemoteInstallLockBusyError) { - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_stop_still_running', - reason: 'A stop on this server may still be running. Retry after it settles.' - } - } - throw error - } - if (result.outcome === 'canceled') { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return result - }) -} diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts deleted file mode 100644 index f0f66ec5e5c..00000000000 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { listEnvironments } from '../../shared/runtime-environment-store' -import type { SshTarget } from '../../shared/ssh-types' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown } = { store: null } - return { - state, - events: new Array(), - connect: vi.fn(), - hasDirectAuthority: vi.fn(), - resolveContext: vi.fn(), - recover: vi.fn(), - readRecord: vi.fn(), - deploy: vi.fn(), - probe: vi.fn(), - startTunnel: vi.fn(), - ensureTunnel: vi.fn(), - closeTunnel: vi.fn(), - readTransaction: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: mocks.connect }), - getSshTargetRegistryStore: () => mocks.state.store, - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-activation-record-store', () => ({ readOrcadActivationRecord: mocks.readRecord })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: mocks.readTransaction -})) -vi.mock('./orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy })) -vi.mock('./orcad-artifact-materializer', () => ({ - materializeOrcadArtifact: async () => '/local/orcad' -})) -vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'build-hash' })) -vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe })) -vi.mock('./orcad-terminal-census-client', () => ({ - collectManagedTerminalCensus: async () => ({ - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 39 - }) -})) -vi.mock('./orcad-managed-tunnel', () => ({ - startOrcadManagedTunnel: mocks.startTunnel, - ensureOrcadManagedTunnel: mocks.ensureTunnel, - closeOrcadManagedTunnel: mocks.closeTunnel -})) - -const { createManagedOrcadEnvironment, getManagedOrcadRuntimeStatus } = - await import('./orcad-runtime-lifecycle') - -const VERSION = '0.1.0+abc123' -const emptyRecord = { active: null, previous: null, activatedAt: null, snapshot: null } - -function readiness() { - const endpoint = 'ws://127.0.0.1:6768' - return { - runtimeId: 'runtime-1', - boundEndpoint: endpoint, - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} - -let userDataPath: string -let target: SshTarget -let flushes: number - -function setupStore(overrides: { repos?: { connectionId: string }[] } = {}) { - const store = { - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => { - flushes += 1 - mocks.events.push('flush') - }, - getFolderWorkspaces: () => [], - getRepos: () => overrides.repos ?? [], - ...emptyDependentStateStore(), - getSshTarget: (id: string) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id: string, updates: Partial) => { - target = { ...target, ...updates } - return target - } - } - mocks.state.store = { - getTarget: (id: string) => (id === target.id ? target : undefined), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the claims read only the store methods stubbed above. - getOrcadRuntimeClaims: () => new SshTargetOrcadClaims(store as never) - } -} - -beforeEach(() => { - vi.resetAllMocks() - mocks.events.length = 0 - flushes = 0 - userDataPath = mkdtempSync(join(tmpdir(), 'orcad-deployment-test-')) - target = { id: 'ssh-1', label: 'Builder', host: 'builder', port: 22, username: 'dev' } - setupStore() - mocks.hasDirectAuthority.mockReturnValue(false) - mocks.connect.mockImplementation(async () => { - mocks.events.push('connect') - return {} - }) - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: emptyRecord, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed, - userDataDir: '/home/dev/.orca' - })) - mocks.recover.mockResolvedValue({ outcome: 'none' }) - mocks.deploy.mockResolvedValue({ outcome: 'installed-and-activated', fullVersion: VERSION }) - mocks.probe.mockResolvedValue(readiness()) - mocks.startTunnel.mockResolvedValue(46_768) - mocks.closeTunnel.mockResolvedValue(undefined) - mocks.ensureTunnel.mockResolvedValue(undefined) -}) - -afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - -const deploy = () => - createManagedOrcadEnvironment(userDataPath, { name: 'Managed', sshTargetId: 'ssh-1' }) - -describe('createManagedOrcadEnvironment', () => { - it('claims the target durably before contacting it, then registers a tunneled server', async () => { - const result = await deploy() - - expect(mocks.events.slice(0, 2)).toEqual(['flush', 'connect']) - expect(result).toMatchObject({ outcome: 'created', activeVersion: VERSION }) - const [environment] = listEnvironments(userDataPath) - expect(environment?.orcadDeployment).toEqual({ - sshTargetId: 'ssh-1', - sshTargetGeneration: 9, - localPort: 46_768, - remotePort: 6_768 - }) - expect(environment?.connectionDependency).toBe('ssh-tunnel') - expect(getManagedOrcadOwnerEnvironmentId(target.owner)).toBe(environment?.id) - expect(target.orcadProvisioning).toEqual({ requestId: environment?.id, name: 'Managed' }) - expect(JSON.stringify(result)).not.toContain('device-token') - expect(mocks.probe).toHaveBeenCalledWith( - expect.objectContaining({ remoteInstallDir: expect.any(String) }), - { - buildHash: 'build-hash', - fullVersion: VERSION - } - ) - expect(mocks.closeTunnel).not.toHaveBeenCalled() - }) - - it('deploys an empty host with a zero census and an unknown one over an active slot', async () => { - await deploy() - expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, - nodePath: '' - }) - }) - - it('refuses a host with direct SSH projects before claiming or connecting', async () => { - setupStore({ repos: [{ connectionId: 'ssh-1' }] }) - await expect(deploy()).rejects.toThrow('repositories or folder workspaces') - expect(target.owner).toBeUndefined() - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a host that saved state still references, naming it, before claiming', async () => { - const leases = [{ ptyId: 'pty-1', state: 'expired' }] - const store = { - ...emptyDependentStateStore({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - getSshRemotePtyLeases: () => leases as never - }), - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => {}, - getFolderWorkspaces: () => [], - getRepos: () => [], - getSshTarget: () => target, - getSshTargets: () => [target], - updateSshTarget: (_id: string, updates: Partial) => - (target = { ...target, ...updates }) - } - mocks.state.store = { - getTarget: () => target, - getOrcadRuntimeClaims: () => new SshTargetOrcadClaims(store) - } - await expect(deploy()).rejects.toThrow('terminal-lease ×1 (pty-1 (expired))') - expect(target.owner).toBeUndefined() - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a connected direct SSH session and a taken server name', async () => { - mocks.hasDirectAuthority.mockReturnValueOnce(true) - await expect(deploy()).rejects.toThrow('Disconnect this SSH host') - await deploy() - target = { ...target, id: 'ssh-2', owner: undefined } - await expect( - createManagedOrcadEnvironment(userDataPath, { name: 'Managed', sshTargetId: 'ssh-2' }) - ).rejects.toThrow('already exists') - }) - - it('does not contact the host when the claim cannot be made durable', async () => { - const claims = new SshTargetOrcadClaims({ - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => { - throw new Error('disk full') - }, - getFolderWorkspaces: () => [], - getRepos: () => [], - ...emptyDependentStateStore(), - getSshTarget: () => target, - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - mocks.state.store = { getTarget: () => target, getOrcadRuntimeClaims: () => claims } - await expect(deploy()).rejects.toThrow('disk full') - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('returns a deferral, keeps the claim and closes nothing it did not open', async () => { - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: VERSION, - code: 'orcad_update_terminal_census_unavailable', - reason: 'unknown census' - }) - await expect(deploy()).resolves.toMatchObject({ outcome: 'deferred', forceable: false }) - expect(listEnvironments(userDataPath)).toEqual([]) - expect(getManagedOrcadOwnerEnvironmentId(target.owner)).not.toBeNull() - expect(mocks.startTunnel).not.toHaveBeenCalled() - }) - - it('resumes an interrupted deploy under the environment id its claim recorded', async () => { - mocks.probe.mockRejectedValueOnce(new Error('readiness unverifiable')) - await expect(deploy()).rejects.toThrow('readiness unverifiable') - const claimedId = getManagedOrcadOwnerEnvironmentId(target.owner) - expect(claimedId).not.toBeNull() - mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: VERSION }) - const result = await deploy() - expect(result).toMatchObject({ outcome: 'already-current' }) - expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([claimedId]) - }) - - it('closes the tunnel it opened when registration fails', async () => { - mocks.probe.mockResolvedValueOnce({ - ...readiness(), - pairing: { available: false, guidance: 'off' } - }) - mocks.startTunnel.mockResolvedValueOnce(46_768) - await expect(deploy()).rejects.toThrow('did not publish a pairing offer') - expect(mocks.closeTunnel).toHaveBeenCalledOnce() - expect(JSON.stringify(mocks.closeTunnel.mock.calls)).not.toContain('device-token') - }) - - it('finishes an already registered server by ensuring its tunnel, without redeploying', async () => { - await deploy() - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: { ...emptyRecord, active: VERSION }, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed, - userDataDir: '/home/dev/.orca' - })) - await expect(deploy()).resolves.toMatchObject({ outcome: 'already-current' }) - expect(mocks.deploy).toHaveBeenCalledOnce() - expect(mocks.ensureTunnel).toHaveBeenCalledOnce() - }) - - it('stops at an interrupted activation the host cannot reconcile yet', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'pending', - code: 'fresh', - reason: 'still fresh' - }) - await expect(deploy()).rejects.toThrow('still fresh') - expect(mocks.deploy).not.toHaveBeenCalled() - }) -}) - -describe('getManagedOrcadRuntimeStatus', () => { - it('reports the activation record and an interrupted transaction without repairing it', async () => { - await deploy() - const [environment] = listEnvironments(userDataPath) - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: { ...emptyRecord, active: VERSION, activatedAt: 'now' }, - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed - })) - mocks.readTransaction.mockResolvedValueOnce({ - operation: 'activate', - phase: 'candidate-ready', - candidateVersion: '0.2.0+def', - startedAt: 'then' - }) - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).resolves.toEqual({ - environmentId: environment?.id, - sshTargetId: 'ssh-1', - activeVersion: VERSION, - previousVersion: null, - activatedAt: 'now', - rollbackAvailable: false, - recovery: { - operation: 'activate', - phase: 'candidate-ready', - version: '0.2.0+def', - startedAt: 'then' - }, - terminals: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 39 }, - deferredUpdate: null - }) - expect(mocks.recover).toHaveBeenCalledTimes(1) - }) - - it('reports an interrupted decommission without finishing it', async () => { - await deploy() - mocks.readTransaction.mockResolvedValueOnce({ - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: VERSION, - startedAt: 'then' - }) - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).resolves.toMatchObject({ - recovery: { operation: 'decommission', phase: 'stop-dispatched', version: VERSION } - }) - }) - - it('refuses a server whose SSH registration was re-created', async () => { - await deploy() - target = { ...target, generation: 10 } - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).rejects.toThrow( - 'no longer valid' - ) - }) -}) diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts deleted file mode 100644 index fe4ee7b79a6..00000000000 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Deploys orcad onto an empty SSH host and pairs this client with it through a loopback tunnel. - * The target claim is the resume point: an interrupted deploy leaves the target owned by the - * environment id it was creating, and the next deploy of that target finishes the same one. - */ -import { randomUUID } from 'node:crypto' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import { redactRuntimeEnvironment } from '../../shared/runtime-environments' -import { - ORCAD_MANAGED_REMOTE_PORT, - type OrcadManagedDeployResult -} from '../../shared/orcad-managed-runtime' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import { - closeOrcadManagedTunnel, - ensureOrcadManagedTunnel, - startOrcadManagedTunnel -} from './orcad-managed-tunnel' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { resolveOrcadRemoteContext } from './orcad-remote-context' -import { deployOrcad } from './orcad-remote-deploy' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' -import { - isForceableOrcadDeferral, - managedOrcadSlot, - probeManagedOrcadReadiness, - requireManagedOrcadInfrastructure -} from './orcad-managed-runtime-context' - -export async function createManagedOrcadEnvironment( - userDataPath: string, - args: { name: string; sshTargetId: string; force?: boolean; signal?: AbortSignal } -): Promise { - return runTargetLifecycle(args.sshTargetId, async () => { - const { connectionManager, targetStore, claims } = requireManagedOrcadInfrastructure() - const environmentId = - getManagedOrcadOwnerEnvironmentId(targetStore.getTarget(args.sshTargetId)?.owner) ?? - randomUUID() - const environments = listEnvironments(userDataPath) - const registered = environments.find((entry) => entry.id === environmentId) - if (registered) { - assertRuntimeEnvironmentNotReconciling(registered) - if (registered.orcadDeployment?.sshTargetId !== args.sshTargetId) { - throw new Error('The SSH target is owned by a server that is not deployed on it.') - } - } - if (environments.some((entry) => entry.id !== environmentId && entry.name === args.name)) { - throw new Error(`A server named "${args.name}" already exists.`) - } - if (hasRegisteredDirectSshAuthority(args.sshTargetId)) { - throw new Error('Disconnect this SSH host before converting it to a managed Orca server.') - } - const claimed = claims.claim(args.sshTargetId, environmentId, args.name) - await claims.flush(args.signal) - const targetGeneration = claimed.generation - if (targetGeneration === undefined) { - throw new Error('The managed Orca SSH registration has no durable generation.') - } - if (registered && registered.orcadDeployment?.sshTargetGeneration !== targetGeneration) { - throw new Error('The saved managed Orca server has a stale SSH target generation.') - } - let environmentRegistered = false - try { - const connection = await connectionManager.connect(claimed) - let context = await resolveOrcadRemoteContext(claimed, connection, args.signal) - const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT, args.signal) - const recovery = await recoverInterruptedOrcadActivation(slot) - if (recovery.outcome === 'pending' || recovery.outcome === 'refused') { - throw new Error(recovery.reason) - } - if (recovery.outcome === 'recovered') { - context = { ...context, activationRecord: await readOrcadActivationRecord(slot) } - } - if (registered) { - environmentRegistered = true - await ensureOrcadManagedTunnel(userDataPath, registered.id) - const activeVersion = context.activationRecord.active - if (!activeVersion) { - throw new Error('The managed Orca server has no active runtime version.') - } - return { - outcome: 'already-current', - environment: redactRuntimeEnvironment(registered), - activeVersion - } - } - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const deployResult = await deployOrcad({ - ...slot, - conn: connection, - localOrcadDir, - target: context.serverTarget, - // Why unknown when a version is active: only the daemon can count its sessions, and - // a deploy that guessed zero would restart over live terminals. - census: context.activationRecord.active - ? { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null } - : { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, - force: args.force - }) - if (deployResult.outcome === 'installed-not-activated') { - return { - outcome: 'deferred', - candidateVersion: deployResult.fullVersion, - code: deployResult.code, - reason: deployResult.reason, - forceable: isForceableOrcadDeferral(deployResult.code) - } - } - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - deployResult.fullVersion, - args.signal - ) - const localPort = await startOrcadManagedTunnel( - environmentId, - claimed, - connection, - ORCAD_MANAGED_REMOTE_PORT - ) - const environment = addManagedOrcadEnvironment(userDataPath, { - id: environmentId, - name: args.name, - pairingCode: tunneledOrcadPairingCode(readiness, localPort), - orcadDeployment: { - sshTargetId: claimed.id, - sshTargetGeneration: targetGeneration, - localPort, - remotePort: ORCAD_MANAGED_REMOTE_PORT - } - }) - environmentRegistered = true - return { - outcome: deployResult.outcome === 'already-active' ? 'already-current' : 'created', - environment: redactRuntimeEnvironment(environment), - activeVersion: deployResult.fullVersion - } - } finally { - if (!environmentRegistered) { - await closeOrcadManagedTunnel(environmentId).catch(() => undefined) - } - } - }) -} diff --git a/src/main/ssh/orcad-runtime-lifecycle.ts b/src/main/ssh/orcad-runtime-lifecycle.ts deleted file mode 100644 index 0562ccce7c7..00000000000 --- a/src/main/ssh/orcad-runtime-lifecycle.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createManagedOrcadEnvironment } from './orcad-runtime-deployment' -export { getManagedOrcadRuntimeStatus } from './orcad-runtime-status' -export { - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} from './orcad-runtime-maintenance' -export { cancelManagedOrcadStop, stopManagedOrcadEnvironment } from './orcad-runtime-decommission' diff --git a/src/main/ssh/orcad-runtime-maintenance.test.ts b/src/main/ssh/orcad-runtime-maintenance.test.ts deleted file mode 100644 index c150dc7b59d..00000000000 --- a/src/main/ssh/orcad-runtime-maintenance.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { rmSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { - createManagedLifecycleHarness, - MANAGED_PREVIOUS_VERSION, - MANAGED_VERSION, - managedReadiness -} from './orcad-managed-lifecycle-test-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown } = { store: null } - return { - state, - resolveContext: vi.fn(), - census: vi.fn(), - deploy: vi.fn(), - rollback: vi.fn(), - recover: vi.fn(), - probe: vi.fn(), - buildHash: vi.fn(), - ensureTunnel: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: async () => ({}) }), - getSshTargetRegistryStore: () => mocks.state.store -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-terminal-census-client', () => ({ collectManagedTerminalCensus: mocks.census })) -vi.mock('./orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy })) -vi.mock('./orcad-remote-rollback', () => ({ rollbackOrcad: mocks.rollback })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe })) -vi.mock('./orcad-remote-build-hash', () => ({ readRemoteOrcadBuildHash: mocks.buildHash })) -vi.mock('./orcad-artifact-materializer', () => ({ - materializeOrcadArtifact: async () => '/local/orcad' -})) -vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'local-hash' })) -vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: async () => null -})) - -const { - getManagedOrcadRuntimeStatus, - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} = await import('./orcad-runtime-lifecycle') - -const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 } -let harness: ReturnType - -beforeEach(() => { - vi.resetAllMocks() - harness = createManagedLifecycleHarness() - mocks.state.store = harness.targetStore - mocks.resolveContext.mockImplementation(async () => harness.context()) - mocks.census.mockResolvedValue(idle) - mocks.probe.mockResolvedValue(managedReadiness()) - mocks.buildHash.mockResolvedValue('previous-hash') - mocks.recover.mockResolvedValue({ outcome: 'none' }) -}) - -afterEach(() => rmSync(harness.userDataPath, { recursive: true, force: true })) - -describe('updateManagedOrcadEnvironment', () => { - it('defers over live or unverifiable terminals and reports the deferral in status', async () => { - mocks.census.mockResolvedValue({ ...idle, liveSessions: null }) - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: '0.3.0+new', - code: 'orcad_update_terminal_census_unavailable', - reason: 'The terminal daemon did not answer a session count.' - }) - const result = await updateManagedOrcadEnvironment(harness.userDataPath, { - selector: 'Managed' - }) - expect(result).toMatchObject({ outcome: 'deferred', forceable: false }) - expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ - census: { liveSessions: null }, - port: 6_768, - nodePath: '' - }) - const status = await getManagedOrcadRuntimeStatus(harness.userDataPath, 'Managed') - expect(status.deferredUpdate).toMatchObject({ - candidateVersion: '0.3.0+new', - code: 'orcad_update_terminal_census_unavailable' - }) - expect(status.terminals.liveSessions).toBeNull() - }) - - it('clears the deferral once an update goes through and keeps an unchanged pairing as is', async () => { - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: '0.3.0+new', - code: 'orcad_update_terminals_running', - reason: 'busy' - }) - await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-and-activated', - fullVersion: '0.3.0+new' - }) - const result = await updateManagedOrcadEnvironment(harness.userDataPath, { - selector: 'Managed', - force: true - }) - expect(result).toMatchObject({ outcome: 'updated', activeVersion: '0.3.0+new' }) - expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), { - buildHash: 'local-hash', - fullVersion: '0.3.0+new' - }) - const [environment] = listEnvironments(harness.userDataPath) - expect(environment?.pairingRevision).toBe(harness.environment.pairingRevision) - expect(environment?.orcadDeployment).toEqual(harness.environment.orcadDeployment) - const status = await getManagedOrcadRuntimeStatus(harness.userDataPath, 'Managed') - expect(status.deferredUpdate).toBeNull() - }) - - it('re-pairs through the same tunnel, keeping the deployment link, when the offer changed', async () => { - mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-and-activated', fullVersion: 'v3' }) - mocks.probe.mockResolvedValueOnce(managedReadiness('rotated-token')) - await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - const [environment] = listEnvironments(harness.userDataPath) - expect(environment?.endpoints[0]?.deviceToken).toBe('rotated-token') - expect(environment?.orcadDeployment).toEqual(harness.environment.orcadDeployment) - }) -}) - -describe('rollbackManagedOrcadEnvironment', () => { - it.each([ - [{ ...idle, liveSessions: 2 }, 'orcad_rollback_terminals_running'], - [{ ...idle, liveSessions: null }, 'orcad_rollback_census_unavailable'] - ])('refuses while terminals run or cannot be counted: %j', async (census, code) => { - mocks.census.mockResolvedValue(census) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', code }) - expect(mocks.rollback).not.toHaveBeenCalled() - }) - - it('refuses with no previous version without contacting the slot', async () => { - mocks.resolveContext.mockImplementation(async () => harness.context({ previous: null })) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_rollback_no_target' }) - expect(mocks.census).not.toHaveBeenCalled() - }) - - it('rolls back against the installed bytes of the previous slot', async () => { - mocks.rollback.mockResolvedValueOnce({ - outcome: 'rolled-back', - target: MANAGED_PREVIOUS_VERSION, - discarded: [MANAGED_VERSION], - verdict: { decision: 'activate', coverage: 'pty-spawn', warnings: [] } - }) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'rolled-back', activeVersion: MANAGED_PREVIOUS_VERSION }) - expect(mocks.rollback.mock.calls[0]?.[0]).toMatchObject({ targetBuildHash: 'previous-hash' }) - expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), { - buildHash: 'previous-hash', - fullVersion: MANAGED_PREVIOUS_VERSION - }) - }) -}) - -describe('recoverManagedOrcadEnvironment', () => { - it('passes a refusal through and leaves the server linked', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_unverifiable', - reason: 'host fenced' - }) - await expect( - recoverManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expect(listEnvironments(harness.userDataPath)).toHaveLength(1) - }) - - it('re-ensures the tunnel once a serving slot is restored', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: MANAGED_VERSION, - readiness: managedReadiness() - }) - await expect( - recoverManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'recovered', activeVersion: MANAGED_VERSION }) - expect(mocks.ensureTunnel).toHaveBeenCalledWith(harness.userDataPath, 'environment-1') - }) -}) diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts deleted file mode 100644 index 712d0d67ae0..00000000000 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Updating, rolling back and recovering a managed orcad, on T6-2's deploy, rollback and recovery. - * Every step reads the terminal census through the server's tunnel first; an unanswered census - * is unverifiable, never zero, so an update over live or uncounted terminals defers (D7). - */ -import { refreshManagedOrcadPairing } from '../../shared/runtime-environment-managed-orcad-store' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { - redactRuntimeEnvironment, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import type { - OrcadManagedDeployResult, - OrcadManagedRecoveryResult, - OrcadManagedRollbackResult -} from '../../shared/orcad-managed-runtime' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import type { ServeReadiness } from '../server/serve-readiness' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { probeActiveOrcadReadiness } from './orcad-active-readiness' -import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from './orcad-daemon-protocol-crossing' -import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel' -import { - clearManagedOrcadUpdateDeferral, - recordManagedOrcadUpdateDeferral -} from './orcad-managed-update-deferrals' -import { - isForceableOrcadDeferral, - managedOrcadInstallDir, - managedOrcadSlot, - probeManagedOrcadReadiness, - requireManagedOrcadEnvironment, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { deployOrcad } from './orcad-remote-deploy' -import { rollbackOrcad } from './orcad-remote-rollback' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' - -type LifecycleArgs = { selector: string; signal?: AbortSignal } - -export function withManagedOrcadLifecycle( - userDataPath: string, - selector: string, - run: (managed: ReturnType) => Promise -): Promise { - const { environment, deployment } = requireManagedOrcadEnvironment(userDataPath, selector) - return runTargetLifecycle(deployment.sshTargetId, async () => { - // Re-read under the queue: a reconciliation or stop may have won the race for it. - const current = requireManagedOrcadEnvironment(userDataPath, environment.id) - assertRuntimeEnvironmentNotReconciling(current.environment) - return run(current) - }) -} - -function refreshPairing( - userDataPath: string, - environment: KnownRuntimeEnvironment, - readiness: ServeReadiness, - localPort: number -): KnownRuntimeEnvironment { - return refreshManagedOrcadPairing( - userDataPath, - environment.id, - tunneledOrcadPairingCode(readiness, localPort) - ) -} - -export function updateManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs & { force?: boolean } -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const census = await collectManagedTerminalCensus( - userDataPath, - environment, - context.activationRecord - ) - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const result = await deployOrcad({ - ...managedOrcadSlot(context, deployment.remotePort, args.signal), - localOrcadDir, - target: context.serverTarget, - census, - force: args.force - }) - if (result.outcome === 'installed-not-activated') { - const deferral = { - outcome: 'deferred' as const, - candidateVersion: result.fullVersion, - code: result.code, - reason: result.reason, - forceable: isForceableOrcadDeferral(result.code) - } - recordManagedOrcadUpdateDeferral(environment.id, deferral) - return deferral - } - clearManagedOrcadUpdateDeferral(environment.id) - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - result.fullVersion, - args.signal - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.fullVersion - } - } - ) -} - -export function rollbackManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const record = context.activationRecord - const target = record.previous - if (!target) { - return { - outcome: 'refused', - code: 'orcad_rollback_no_target', - reason: 'This server has no previous version to roll back to.' - } - } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show - // that build would reach terminals that are still running. - if (census.liveSessions !== 0) { - return { - outcome: 'refused', - code: - census.liveSessions === null - ? 'orcad_rollback_census_unavailable' - : 'orcad_rollback_terminals_running', - reason: - census.liveSessions === null - ? 'The server did not answer how many terminals it runs. Retry when it answers.' - : 'Close the terminals running on this server before rolling it back.' - } - } - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - const targetDir = managedOrcadInstallDir(context, target) - const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) - const result = await rollbackOrcad({ - ...slot, - record, - census, - targetBuildHash, - targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL - }) - if (result.outcome !== 'rolled-back') { - return result - } - const readiness = await probeActiveOrcadReadiness( - { ...slot, remoteInstallDir: targetDir }, - { buildHash: targetBuildHash, fullVersion: result.target } - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: 'rolled-back', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.target, - discarded: result.discarded - } - } - ) -} - -/** Finishes or undoes an interrupted activation, rollback or decommission on the host. */ -export function recoverManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const result = await recoverInterruptedOrcadActivation( - managedOrcadSlot(context, deployment.remotePort, args.signal) - ) - if (result.outcome !== 'recovered') { - return result - } - const updated = result.readiness - ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) - : environment - if (result.activeVersion) { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return { - outcome: 'recovered', - resolution: result.resolution, - activeVersion: result.activeVersion, - environment: redactRuntimeEnvironment(updated) - } - } - ) -} diff --git a/src/main/ssh/orcad-runtime-status.ts b/src/main/ssh/orcad-runtime-status.ts deleted file mode 100644 index 101eb6d81e5..00000000000 --- a/src/main/ssh/orcad-runtime-status.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { OrcadManagedRuntimeStatus } from '../../shared/orcad-managed-runtime' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import type { OrcadActivationTransaction } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - requireManagedOrcadEnvironment, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' -import { readManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' - -/** Read-only: what the host's activation record and journal say, without repairing either. */ -export async function getManagedOrcadRuntimeStatus( - userDataPath: string, - selector: string, - signal?: AbortSignal -): Promise { - const { environment, deployment } = requireManagedOrcadEnvironment(userDataPath, selector) - return runTargetLifecycle(deployment.sshTargetId, async () => { - const context = await resolveLinkedOrcadContext(environment, deployment, signal) - const record = context.activationRecord - const transaction = await readOrcadActivationTransaction({ - conn: context.connection, - host: context.host, - remoteHome: context.remoteHome, - signal - }) - return { - environmentId: environment.id, - sshTargetId: context.target.id, - activeVersion: record.active, - previousVersion: record.previous, - activatedAt: record.activatedAt, - rollbackAvailable: Boolean(record.previous && record.snapshot), - recovery: transaction ? managedRecoveryStatus(transaction) : null, - terminals: await collectManagedTerminalCensus(userDataPath, environment, record), - deferredUpdate: readManagedOrcadUpdateDeferral(environment.id) - } - }) -} - -function managedRecoveryStatus( - transaction: OrcadActivationTransaction -): NonNullable { - switch (transaction.operation) { - case 'activate': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.candidateVersion, - startedAt: transaction.startedAt - } - case 'rollback': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.targetVersion, - startedAt: transaction.startedAt - } - case 'decommission': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.activeVersion, - startedAt: transaction.startedAt - } - } -} diff --git a/src/main/ssh/orcad-ssh-provisioning.test.ts b/src/main/ssh/orcad-ssh-provisioning.test.ts deleted file mode 100644 index 4640f0b93d5..00000000000 --- a/src/main/ssh/orcad-ssh-provisioning.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshTarget } from '../../shared/ssh-types' -import { normalizeSshTarget } from '../persistence/leasing-ssh-ptys/ssh-normalization' - -const mocks = vi.hoisted(() => { - const targets: SshTarget[] = [] - const deployedTargets: string[] = [] - const targetStore: Record = {} - return { - targets, - deployedTargets, - update: vi.fn(), - deploy: vi.fn(), - flush: vi.fn(), - add: vi.fn(), - rotate: vi.fn(), - targetStore - } -}) - -vi.mock('./orcad-managed-runtime-context', () => ({ - requireManagedOrcadTargetStore: () => mocks.targetStore -})) -vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) -vi.mock('./ssh-provider-authority', () => ({ rotateSshProviderAuthority: mocks.rotate })) -vi.mock('../../shared/runtime-environment-store', () => ({ - listEnvironments: () => - mocks.deployedTargets.map((sshTargetId) => ({ orcadDeployment: { sshTargetId } })) -})) - -import { - createOrcadSshHost, - listPendingOrcadSshProvisioning, - resumeOrcadSshHost -} from './orcad-ssh-provisioning' - -const request = { - requestId: 'request-1', - name: 'Build host', - target: { label: 'Build host', host: 'builder', port: 22, username: 'dev' } -} -const deployed = { - outcome: 'created', - environment: { id: 'environment-1' }, - activeVersion: 'node-1' -} - -beforeEach(() => { - vi.clearAllMocks() - mocks.targets.length = 0 - mocks.deployedTargets.length = 0 - mocks.flush.mockReset().mockResolvedValue(undefined) - mocks.deploy.mockReset().mockResolvedValue(deployed) - mocks.add.mockImplementation((input) => { - const target = normalizeSshTarget({ - ...input, - id: `ssh-${mocks.targets.length + 1}`, - generation: 1 - }) - mocks.targets.push(target) - return target - }) - mocks.update.mockImplementation((id: string, updates: Partial) => { - const index = mocks.targets.findIndex((target) => target.id === id) - mocks.targets[index] = { ...mocks.targets[index]!, ...updates } - return mocks.targets[index] - }) - mocks.targetStore = { - addTarget: mocks.add, - updateTarget: mocks.update, - lastRepoReadoptions: [], - getOrcadRuntimeClaims: () => ({ listTargets: () => mocks.targets, flush: mocks.flush }) - } -}) - -describe('managed SSH host provisioning', () => { - it('persists intent before running the existing migration/deployment preflight', async () => { - mocks.deploy.mockImplementation(async () => { - expect(mocks.flush).toHaveBeenCalledOnce() - expect(mocks.targets[0]?.orcadProvisioning).toEqual({ - requestId: 'request-1', - name: 'Build host' - }) - return deployed - }) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - requestId: 'request-1', - name: 'Build host', - sshTargetId: 'ssh-1', - result: deployed - }) - expect(mocks.deploy).toHaveBeenCalledWith('/profile', { - name: 'Build host', - sshTargetId: 'ssh-1' - }) - }) - - it('does not contact a host if durable intent publication fails', async () => { - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(createOrcadSshHost('/profile', request)).rejects.toThrow('disk full') - expect(mocks.deploy).not.toHaveBeenCalled() - await resumeOrcadSshHost('/profile', 'request-1') - expect(mocks.add).toHaveBeenCalledOnce() - }) - - it('retains the same request across preflight refusal and a reconstructed target store', async () => { - mocks.deploy.mockRejectedValueOnce(new Error('live-or-unverifiable terminal leases')) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - result: { outcome: 'pending', reason: 'live-or-unverifiable terminal leases' } - }) - mocks.targets.splice(0, 1, JSON.parse(JSON.stringify(mocks.targets[0]))) - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([ - { requestId: 'request-1', name: 'Build host', sshTargetId: 'ssh-1' } - ]) - await expect(resumeOrcadSshHost('/profile', 'request-1')).resolves.toMatchObject({ - result: deployed - }) - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.deploy).toHaveBeenCalledTimes(2) - }) - - it('restores a host with re-adopted projects as direct SSH instead of hiding them', async () => { - const readoptions = [{ oldTargetId: 'old', newTargetId: 'ssh-1', repoIds: ['repo-1'] }] - mocks.targetStore.lastRepoReadoptions = readoptions - const result = await createOrcadSshHost('/profile', request) - expect(result.repoReadoptions).toEqual(readoptions) - expect(result.result).toMatchObject({ outcome: 'pending' }) - expect(mocks.rotate).toHaveBeenCalledWith('old') - expect(mocks.rotate).toHaveBeenCalledWith('ssh-1') - expect(mocks.targets[0]?.orcadProvisioning).toBeUndefined() - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - expect(mocks.deploy).not.toHaveBeenCalled() - }) - - it('returns activation deferral without force, deletion, or another target', async () => { - const deferred = { - outcome: 'deferred', - reason: 'unverifiable', - code: 'blocked', - candidateVersion: 'node-1' - } - mocks.deploy.mockResolvedValueOnce(deferred) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - result: deferred - }) - expect(listPendingOrcadSshProvisioning('/profile')).toHaveLength(1) - expect(mocks.targets).toHaveLength(1) - }) - - it('serializes repeated requests and preserves normalization on exact retries', async () => { - const normalizedRequest = { - ...request, - target: { ...request.target, relayGracePeriodSeconds: 10800 } - } - await Promise.all([ - createOrcadSshHost('/profile', normalizedRequest), - createOrcadSshHost('/profile', normalizedRequest) - ]) - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.targets).toHaveLength(1) - }) - - it('rejects request identity reuse for different host data or name', async () => { - await createOrcadSshHost('/profile', request) - await expect(createOrcadSshHost('/profile', { ...request, name: 'Other' })).rejects.toThrow( - 'already belongs' - ) - await expect( - createOrcadSshHost('/profile', { - ...request, - target: { ...request.target, identityFile: 'other' } - }) - ).rejects.toThrow('already belongs') - expect(mocks.deploy).toHaveBeenCalledOnce() - }) - - it('does not create a duplicate raw host when another request registered the endpoint', async () => { - await createOrcadSshHost('/profile', request) - await expect( - createOrcadSshHost('/profile', { ...request, requestId: 'request-2' }) - ).rejects.toThrow('already registered') - expect(mocks.add).toHaveBeenCalledOnce() - }) - - it('does not duplicate an occupied config alias or endpoint under a different label', async () => { - await createOrcadSshHost('/profile', request) - await expect( - createOrcadSshHost('/profile', { - ...request, - requestId: 'request-2', - target: { ...request.target, configHost: 'BUILDER', host: 'resolved-address' } - }) - ).rejects.toThrow('already registered') - await expect( - createOrcadSshHost('/profile', { - ...request, - requestId: 'request-3', - target: { ...request.target, label: 'Different', configHost: 'other-alias' } - }) - ).rejects.toThrow('already registered') - }) - - it.each([ - { configHost: 3 }, - { identityFile: false }, - { gssapiAuthentication: 'yes' }, - { systemSshConnectionReuse: 1 }, - { portForwards: [{ localPort: -1 }] } - ])('rejects malformed optional connection fields before durable registration: %j', (invalid) => { - expect(() => - createOrcadSshHost('/profile', { - ...request, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately malformed renderer input. - target: { ...request.target, ...invalid } as never - }) - ).toThrow() - expect(mocks.add).not.toHaveBeenCalled() - }) - - it('omits completed requests from pending discovery but retains their retry identity', async () => { - await createOrcadSshHost('/profile', request) - mocks.deployedTargets.push('ssh-1') - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - await resumeOrcadSshHost('/profile', 'request-1') - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.deploy).toHaveBeenCalledTimes(2) - }) - - it('does not provision imported config entries without explicit intent', () => { - mocks.targets.push({ ...request.target, id: 'imported', source: 'ssh-config' }) - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - expect(mocks.deploy).not.toHaveBeenCalled() - }) - - it('rejects invalid input and unknown retry IDs without writing a target', async () => { - expect(() => createOrcadSshHost('/profile', { ...request, requestId: '' })).toThrow( - 'request id' - ) - expect(() => - createOrcadSshHost('/profile', { ...request, target: { ...request.target, port: 0 } }) - ).toThrow('port') - await expect(resumeOrcadSshHost('/profile', 'missing')).rejects.toThrow('not found') - expect(mocks.add).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/orcad-ssh-provisioning.ts b/src/main/ssh/orcad-ssh-provisioning.ts deleted file mode 100644 index 8b904e7f272..00000000000 --- a/src/main/ssh/orcad-ssh-provisioning.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { z } from 'zod' -import type { - OrcadSshPendingProvisioning, - OrcadSshProvisioningRequest, - OrcadSshProvisioningResult -} from '../../shared/orcad-ssh-provisioning' -import type { SshRepoReadoption, SshTarget, SshTargetCreateInput } from '../../shared/ssh-types' -import { EphemeralVmRecipeSshTargetSchema } from '../../shared/ephemeral-vm-recipes' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { normalizeSshTarget } from '../persistence/leasing-ssh-ptys/ssh-normalization' -import { requireManagedOrcadTargetStore } from './orcad-managed-runtime-context' -import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' -import { rotateSshProviderAuthority } from './ssh-provider-authority' - -// Why no port forwards: a managed server is only created on an empty host. -const provisioningTargetSchema = EphemeralVmRecipeSshTargetSchema.omit({ - portForwards: true -}).extend({ - gssapiAuthentication: z.boolean().optional(), - systemSshConnectionReuse: z.boolean().optional(), - source: z.enum(['manual', 'ssh-config']).optional(), - lastRequiredPassphrase: z.boolean().optional() -}) - -/** Requests whose server is not registered yet; a completed one keeps its intent for idempotent retries. */ -export function listPendingOrcadSshProvisioning( - userDataPath: string -): OrcadSshPendingProvisioning[] { - const deployed = new Set( - listEnvironments(userDataPath).flatMap((environment) => - environment.orcadDeployment ? [environment.orcadDeployment.sshTargetId] : [] - ) - ) - return requireManagedOrcadTargetStore() - .getOrcadRuntimeClaims() - .listTargets() - .flatMap((target) => - target.orcadProvisioning && !deployed.has(target.id) - ? [{ ...target.orcadProvisioning, sshTargetId: target.id }] - : [] - ) -} - -export function createOrcadSshHost( - userDataPath: string, - request: OrcadSshProvisioningRequest -): Promise { - const requestId = requireRequestId(request?.requestId) - const name = requireText(request?.name, 'Server name') - const targetInput = parseTarget(request?.target) - return runTargetLifecycle(`orcad-provision:${userDataPath}:${requestId}`, async () => { - const targetStore = requireManagedOrcadTargetStore() - const targets = targetStore.getOrcadRuntimeClaims().listTargets() - const existing = targets.find((entry) => entry.orcadProvisioning?.requestId === requestId) - if (existing) { - if (existing.orcadProvisioning?.name !== name || !matchesRequest(existing, targetInput)) { - throw new Error('This provisioning request already belongs to another host or server name.') - } - return provision(userDataPath, existing, []) - } - if (targets.some((entry) => sameEndpoint(entry, targetInput))) { - throw new Error('That SSH host is already registered. Use its existing server or SSH entry.') - } - const target = targetStore.addTarget({ - ...targetInput, - source: 'manual', - orcadProvisioning: { requestId, name } - }) - const repoReadoptions = [...targetStore.lastRepoReadoptions] - targetStore.lastRepoReadoptions = [] - for (const targetId of new Set( - repoReadoptions.flatMap(({ oldTargetId, newTargetId }) => [oldTargetId, newTargetId]) - )) { - rotateSshProviderAuthority(targetId) - } - if (repoReadoptions.length > 0) { - // Why visible: re-adopted projects make the host non-empty; hiding it would hide them too. - targetStore.updateTarget(target.id, { orcadProvisioning: undefined }) - return { - requestId, - name, - sshTargetId: target.id, - repoReadoptions, - result: { - outcome: 'pending', - reason: - 'This host already has Orca projects, so it was restored as a direct SSH host. ' + - 'A managed server can only be created on an empty host.' - } - } - } - return provision(userDataPath, target, repoReadoptions) - }) -} - -export function resumeOrcadSshHost( - userDataPath: string, - requestIdInput: string -): Promise { - const requestId = requireRequestId(requestIdInput) - return runTargetLifecycle(`orcad-provision:${userDataPath}:${requestId}`, async () => { - const target = requireManagedOrcadTargetStore() - .getOrcadRuntimeClaims() - .listTargets() - .find((entry) => entry.orcadProvisioning?.requestId === requestId) - if (!target) { - throw new Error('The managed SSH provisioning request was not found.') - } - return provision(userDataPath, target, []) - }) -} - -async function provision( - userDataPath: string, - target: SshTarget, - repoReadoptions: SshRepoReadoption[] -): Promise { - const intent = target.orcadProvisioning - if (!intent) { - throw new Error('The managed SSH provisioning request was not found.') - } - // Why first: the intent must survive a crash before the host is contacted. - await requireManagedOrcadTargetStore().getOrcadRuntimeClaims().flush() - const base = { ...intent, sshTargetId: target.id, repoReadoptions } - try { - return { - ...base, - result: await createManagedOrcadEnvironment(userDataPath, { - name: intent.name, - sshTargetId: target.id - }) - } - } catch (error) { - return { - ...base, - result: { - outcome: 'pending', - reason: error instanceof Error ? error.message : 'Managed SSH provisioning failed.' - } - } - } -} - -function requireText(value: unknown, label: string): string { - if (typeof value !== 'string' || !value.trim() || value.length > 1_024) { - throw new Error(`${label} is required and must not exceed 1024 characters.`) - } - return value.trim() -} - -function requireRequestId(value: unknown): string { - if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) { - throw new Error('A stable managed SSH provisioning request id is required.') - } - return value -} - -function parseTarget(value: unknown): SshTargetCreateInput { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('A new unowned SSH host is required.') - } - const { - id: _id, - generation: _generation, - orcadProvisioning: _intent, - owner, - ...raw - }: Record = { ...value } - if (owner !== undefined) { - throw new Error('A new unowned SSH host is required.') - } - const target = provisioningTargetSchema.parse(raw) - return { - ...target, - label: requireText(target.label, 'SSH host label'), - host: requireText(target.host, 'SSH host'), - username: target.username.trim(), - configHost: target.configHost?.trim() || target.host.trim() - } -} - -function sameEndpoint(left: SshTarget, right: SshTargetCreateInput): boolean { - const alias = normalizeSshConfigAlias(right.configHost ?? right.host) - if ( - alias && - [left.configHost, left.label].some((value) => normalizeSshConfigAlias(value) === alias) - ) { - return true - } - return left.host === right.host && left.port === right.port && left.username === right.username -} - -function matchesRequest(target: SshTarget, input: SshTargetCreateInput): boolean { - const stored: Record = { ...target } - return Object.entries(normalizeSshTarget({ ...input, id: target.id })).every( - ([key, value]) => key === 'source' || JSON.stringify(stored[key]) === JSON.stringify(value) - ) -} diff --git a/src/main/ssh/orcad-state-snapshot-shell.test.ts b/src/main/ssh/orcad-state-snapshot-shell.test.ts deleted file mode 100644 index ff6c263a977..00000000000 --- a/src/main/ssh/orcad-state-snapshot-shell.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' -import { - captureOrcadStateSnapshotCommand, - clearOrcadStateSnapshotMembersCommand, - parseOrcadSnapshotCapture, - parseOrcadSnapshotRestore, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const posix = getRemoteHostPlatform('linux-x64') - -async function sh(command: string): Promise { - const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) - return result.stdout -} - -describe.skipIf(process.platform === 'win32')('snapshot commands on a real shell', () => { - let base: string - let root: string - let snapshot: string - - beforeEach(() => { - base = mkdtempSync(join(tmpdir(), 'orcad-snapshot-shell-')) - root = join(base, 'root') - snapshot = join(base, 'snapshots', 'pre-1') - mkdirSync(join(root, 'profiles'), { recursive: true }) - mkdirSync(join(root, 'daemon'), { recursive: true }) - writeFileSync(join(root, 'profiles', 'p.json'), 'old') - writeFileSync(join(root, 'daemon', 'token'), 'live-daemon') - }) - - afterEach(() => { - rmSync(base, { recursive: true, force: true }) - }) - - it('restores members, drops files the newer build added, and leaves the daemon alone', async () => { - expect( - parseOrcadSnapshotCapture(await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('captured') - writeFileSync(join(root, 'profiles', 'p.json'), 'migrated') - writeFileSync(join(root, 'profiles', 'added.json'), 'new') - writeFileSync(join(root, 'daemon', 'token'), 'rotated') - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('restored') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('old') - expect(existsSync(join(root, 'profiles', 'added.json'))).toBe(false) - expect(readFileSync(join(root, 'daemon', 'token'), 'utf8')).toBe('rotated') - expect(existsSync(join(root, '.orcad-state-restore-stage'))).toBe(false) - }) - - it('keeps live state when the archive cannot be extracted', async () => { - await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot)) - writeFileSync(join(snapshot, 'state.tar'), 'not a tar archive') - writeFileSync(join(root, 'profiles', 'p.json'), 'current') - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('failed') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('current') - }) - - it('reruns cleanly after a restore interrupted between removal and replacement', async () => { - await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot)) - // Simulates a crash that left the stage behind and the live members already removed. - mkdirSync(join(root, '.orcad-state-restore-stage', 'profiles'), { recursive: true }) - rmSync(join(root, 'profiles'), { recursive: true }) - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('restored') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('old') - }) - - it('clears only the snapshot members for a root that started empty', async () => { - expect( - parseOrcadSnapshotRestore(await sh(clearOrcadStateSnapshotMembersCommand(posix, root))) - ).toBe('restored') - expect(existsSync(join(root, 'profiles'))).toBe(false) - expect(readFileSync(join(root, 'daemon', 'token'), 'utf8')).toBe('live-daemon') - }) -}) diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts index c8740507698..f660e95e5a4 100644 --- a/src/main/ssh/orcad-state-snapshot.test.ts +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -4,15 +4,12 @@ import { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS, captureOrcadStateSnapshotCommand, - clearOrcadStateSnapshotMembersCommand, compareOrcadStateSnapshotCommand, newestStateMtimeCommand, orcadSnapshotDirName, parseNewestStateMtimeSeconds, parseOrcadSnapshotCapture, - parseOrcadSnapshotPresence, parseOrcadSnapshotRestore, - probeOrcadStateSnapshotCommand, restoreOrcadStateSnapshotCommand } from './orcad-state-snapshot' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -63,11 +60,9 @@ describe('capturing the pre-activation snapshot', () => { }) describe('restoring the snapshot', () => { - it('proves the archive extracts before clearing the live members', () => { + it('clears the members before extracting, so files the new build added do not survive', () => { const command = restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP) - const extract = command.indexOf(`tar -C '${ROOT}/.orcad-state-restore-stage'`) - expect(extract).toBeGreaterThan(-1) - expect(extract).toBeLessThan(command.indexOf(`rm -rf '${ROOT}'/'profiles'`)) + expect(command.indexOf('rm -rf')).toBeLessThan(command.indexOf('tar -C')) }) it('reports a missing archive instead of extracting nothing and claiming success', () => { @@ -76,15 +71,6 @@ describe('restoring the snapshot', () => { expect(parseOrcadSnapshotRestore('RESTORED')).toBe('restored') expect(parseOrcadSnapshotRestore('FAILED')).toBe('failed') }) - - it.each([ - ['PRESENT', 'present'], - ['ABSENT', 'absent'], - ['', 'unverifiable'], - ['bash: tar: command not found', 'unverifiable'] - ])('reads snapshot presence %j as %s, never treating silence as absence', (out, expected) => { - expect(parseOrcadSnapshotPresence(out)).toBe(expected) - }) }) describe('detecting writes since activation', () => { @@ -108,8 +94,6 @@ describe('Windows hosts', () => { it.each([ ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], - ['clear', () => clearOrcadStateSnapshotMembersCommand(windows, ROOT)], - ['presence', () => probeOrcadStateSnapshotCommand(windows, SNAP)], ['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['mtime', () => newestStateMtimeCommand(windows, ROOT)] ])('refuses %s rather than emitting a POSIX command', (_label, build) => { diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts index a5ce56d7339..7d062275442 100644 --- a/src/main/ssh/orcad-state-snapshot.ts +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -47,8 +47,6 @@ function assertPlainMemberName(member: string): string { return member } -const RESTORE_STAGE_DIRNAME = '.orcad-state-restore-stage' - function noSymlinkedStateCommand(path: string): string { return `links=$(find ${path} -type l -print) && [ -z "$links" ]` } @@ -59,11 +57,6 @@ export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): st return `pre-${fullVersion}-${takenAtMs}` } -/** The newer build's state, kept so an interrupted rollback can put it back. */ -export function orcadRollbackRescueDirName(fullVersion: string, takenAtMs: number): string { - return `rollback-rescue-${fullVersion}-${takenAtMs}` -} - /** * Capture the snapshot, or report why there is nothing to capture. * @@ -125,25 +118,12 @@ export function probeOrcadStateSnapshotCommand( return `test -f ${archive} && echo PRESENT || echo ABSENT` } -export type OrcadSnapshotPresence = 'present' | 'absent' | 'unverifiable' - -/** A lost probe is `unverifiable`, never `absent`. */ -export function parseOrcadSnapshotPresence(output: string): OrcadSnapshotPresence { - const value = output.trim().split('\n').pop()?.trim() - if (value === 'PRESENT') { - return 'present' - } - return value === 'ABSENT' ? 'absent' : 'unverifiable' -} - /** * Restore the snapshot over the data root. * - * Three things make this safe to run: the archive is extracted into a stage first, so an - * unreadable archive fails before live state is touched; the members are then removed before - * the staged copies move in (so a file the new version added is gone rather than - * half-shadowed); and neither step can reach `/daemon`, because the member list never - * names it. + * Two things make this safe to run: the members are removed before extraction (so a file the + * new version added is gone rather than half-shadowed), and neither the removal nor the + * extraction can reach `/daemon`, because the member list never names it. * * The caller must have stopped orcad first. This does not check — it cannot, from a shell — * so `orcad-remote-deploy.ts` owns that ordering. @@ -156,43 +136,17 @@ export function restoreOrcadStateSnapshotCommand( assertPosixHost(host) const root = shellEscape(userDataDir) const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) - const stage = shellEscape(joinRemotePath(host, userDataDir, RESTORE_STAGE_DIRNAME)) const removals = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `rm -rf ${root}/${shellEscape(member)}` - ).join(' && ') - const replacements = ORCAD_SNAPSHOT_MEMBERS.map((member) => { - const name = shellEscape(member) - return `if [ -e ${stage}/${name} ]; then mv ${stage}/${name} ${root}/${name}; fi` - }).join(' && ') - const stagedMemberChecks = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `[ -e ${stage}/${shellEscape(member)} ]` - ).join(' || ') + (member) => `rm -rf ${root}/${shellEscape(member)};` + ).join(' ') return [ `test -f ${archive} || { echo MISSING; exit 0; };`, - 'umask 077;', `test -d ${root} || mkdir -p ${root};`, - // Re-extracting from the intact archive makes an interrupted restore safe to rerun. - `rm -rf ${stage}; mkdir -p ${stage} || { echo FAILED; exit 0; };`, - // Extraction proves every archived byte is readable before live state is removed. - `tar -C ${stage} -xf ${archive} 2>/dev/null || { rm -rf ${stage}; echo FAILED; exit 0; };`, - `${stagedMemberChecks} || { rm -rf ${stage}; echo FAILED; exit 0; };`, - `if ${removals} && ${replacements}; then rm -rf ${stage}; echo RESTORED; else echo FAILED; fi` + removals, + `tar -C ${root} -xf ${archive} && echo RESTORED || echo FAILED` ].join(' ') } -/** Restore an originally empty state root after a candidate populated it. */ -export function clearOrcadStateSnapshotMembersCommand( - host: RemoteHostPlatform, - userDataDir: string -): string { - assertPosixHost(host) - const root = shellEscape(userDataDir) - const removals = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `rm -rf ${root}/${shellEscape(member)}` - ).join(' && ') - return `test -d ${root} || mkdir -p ${root}; if ${removals}; then echo RESTORED; else echo FAILED; fi` -} - export type OrcadSnapshotRestore = 'restored' | 'missing' | 'failed' export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore { diff --git a/src/main/ssh/orcad-terminal-census-client.test.ts b/src/main/ssh/orcad-terminal-census-client.test.ts deleted file mode 100644 index feb3bb15cd9..00000000000 --- a/src/main/ssh/orcad-terminal-census-client.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { - createEnvironmentFromPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { emptyOrcadActivationRecord } from './orcad-activation-record' - -const mocks = vi.hoisted(() => ({ send: vi.fn(), ensure: vi.fn() })) -vi.mock('../../shared/remote-runtime-client', () => ({ - sendRemoteRuntimeRequestWithStatusPreflight: mocks.send -})) -vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensure })) - -const { collectManagedTerminalCensus, collectRemoteOrcadTerminalCensus } = - await import('./orcad-terminal-census-client') - -const environment: KnownRuntimeEnvironment = createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Managed', - now: 1, - offer: { v: 2, endpoint: 'ws://127.0.0.1:46768', deviceToken: 't', publicKeyB64: 'k' }, - connectionDependency: 'ssh-tunnel' -}) -const active = { - ...emptyOrcadActivationRecord(), - active: '1.0.0', - activatedAt: '2026-01-01T00:00:00.000Z' -} -const unverifiable = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} -const census = { liveSessions: 2, startedSinceActivation: 1, daemonProtocolVersion: 7 } - -function answerWith(capabilities: string[], response: unknown) { - mocks.send.mockImplementation(async (_pairing, _method, _params, _timeout, validate) => { - validate({ ok: true, result: { capabilities } }) - return response - }) -} - -describe('managed orcad terminal census client', () => { - beforeEach(() => { - vi.resetAllMocks() - mocks.ensure.mockResolvedValue(undefined) - }) - - it('reads an idle census without contacting a host that has nothing active', async () => { - await expect( - collectRemoteOrcadTerminalCensus(environment, { ...active, active: null }) - ).resolves.toEqual({ liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }) - expect(mocks.send).not.toHaveBeenCalled() - }) - - it('asks an advertising host with the activation time', async () => { - answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: census }) - await expect(collectRemoteOrcadTerminalCensus(environment, active)).resolves.toEqual(census) - expect(mocks.send.mock.calls[0]?.slice(1, 3)).toEqual([ - 'orcad.terminalCensus', - { activatedAt: Date.parse(active.activatedAt) } - ]) - }) - - it.each([ - ['an older host without the capability', () => answerWith([], { ok: true, result: census })], - [ - 'method not found', - () => - answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { - ok: false, - error: { code: 'method_not_found', message: 'no' } - }) - ], - [ - 'a malformed answer', - () => answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: {} }) - ], - ['loss of contact', () => mocks.send.mockRejectedValue(new Error('socket closed'))] - ])('reads %s as unverifiable, never as zero', async (_label, arrange) => { - arrange() - await expect(collectRemoteOrcadTerminalCensus(environment, active)).resolves.toEqual( - unverifiable - ) - }) - - it('reads an unparseable activation time as unverifiable', async () => { - await expect( - collectRemoteOrcadTerminalCensus(environment, { ...active, activatedAt: 'later' }) - ).resolves.toEqual(unverifiable) - expect(mocks.send).not.toHaveBeenCalled() - }) - - it('reads a tunnel that cannot open as unverifiable', async () => { - mocks.ensure.mockRejectedValue(new Error('SSH unavailable')) - await expect(collectManagedTerminalCensus('/profile', environment, active)).resolves.toEqual( - unverifiable - ) - }) -}) diff --git a/src/main/ssh/orcad-terminal-census-client.ts b/src/main/ssh/orcad-terminal-census-client.ts deleted file mode 100644 index d039fa8e120..00000000000 --- a/src/main/ssh/orcad-terminal-census-client.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Asks a managed orcad, through its tunnel, how many terminals its daemon runs. Every failure, - * including an older host without the method, reads as an unverifiable census, never as zero. - */ -import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import { - getPreferredPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { - ORCAD_TERMINAL_CENSUS_METHOD, - OrcadTerminalCensusSchema, - type OrcadTerminalCensus -} from '../../shared/orcad-terminal-census' -import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote-runtime-client' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel' - -export const UNVERIFIABLE_ORCAD_TERMINAL_CENSUS: OrcadTerminalCensus = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} - -export async function collectRemoteOrcadTerminalCensus( - environment: KnownRuntimeEnvironment, - record: OrcadActivationRecord, - timeoutMs = 15_000 -): Promise { - if (!record.active) { - return collectIdle() - } - const activatedAt = record.activatedAt ? Date.parse(record.activatedAt) : Number.NaN - if (!Number.isFinite(activatedAt) || activatedAt < 0) { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } - try { - const response = await sendRemoteRuntimeRequestWithStatusPreflight( - getPreferredPairingOffer(environment), - ORCAD_TERMINAL_CENSUS_METHOD, - { activatedAt }, - timeoutMs, - (status) => { - if ( - !status.ok || - !status.result.capabilities?.includes(ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY) - ) { - throw new Error('The managed Orca server does not report a terminal census.') - } - }, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - return response.ok - ? OrcadTerminalCensusSchema.parse(response.result) - : UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } catch { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } -} - -/** The census through the server's ensured tunnel; a tunnel that cannot open is unverifiable. */ -export async function collectManagedTerminalCensus( - userDataPath: string, - environment: KnownRuntimeEnvironment, - record: OrcadActivationRecord -): Promise { - if (!record.active) { - return collectIdle() - } - try { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } catch { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } - return collectRemoteOrcadTerminalCensus(environment, record) -} - -function collectIdle(): OrcadTerminalCensus { - return { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } -} diff --git a/src/main/ssh/orcad-tunneled-pairing.test.ts b/src/main/ssh/orcad-tunneled-pairing.test.ts deleted file mode 100644 index ee49783ab62..00000000000 --- a/src/main/ssh/orcad-tunneled-pairing.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { encodePairingOffer, parsePairingCode, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import type { ServeReadiness } from '../server/serve-readiness' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' - -function readiness(endpoint = 'ws://[::1]:6768/runtime?mode=paired#fragment'): ServeReadiness { - return { - runtimeId: 'runtime-1', - boundEndpoint: 'ws://127.0.0.1:6768', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: 'device-token', - publicKeyB64: 'public-key', - pairedDeviceId: 'device-1' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} - -describe('tunneledOrcadPairingCode', () => { - it('rewrites only the endpoint authority for the local tunnel', () => { - const rewritten = parsePairingCode(tunneledOrcadPairingCode(readiness(), 46_768)) - - expect(rewritten).toEqual({ - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768/runtime?mode=paired#fragment', - deviceToken: 'device-token', - publicKeyB64: 'public-key', - pairedDeviceId: 'device-1' - }) - }) - - it('refuses a non-loopback offer', () => { - expect(() => tunneledOrcadPairingCode(readiness('wss://runtime.example.com'), 46_768)).toThrow( - 'not loopback-only' - ) - }) -}) diff --git a/src/main/ssh/orcad-tunneled-pairing.ts b/src/main/ssh/orcad-tunneled-pairing.ts deleted file mode 100644 index 53432181884..00000000000 --- a/src/main/ssh/orcad-tunneled-pairing.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { encodePairingOffer, parsePairingCode } from '../../shared/pairing' -import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' -import type { ServeReadiness } from '../server/serve-readiness' - -export function tunneledOrcadPairingCode(readiness: ServeReadiness, localPort: number): string { - if (!readiness.pairing.available) { - throw new Error( - `The managed Orca server did not publish a pairing offer: ${readiness.pairing.guidance}` - ) - } - const offer = parsePairingCode(readiness.pairing.url) - if (!offer) { - throw new Error('The managed Orca server published an invalid pairing offer.') - } - const endpoint = new URL(offer.endpoint) - if ( - (endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') || - classifyRemotePairingHostname(endpoint.hostname) !== 'loopback' - ) { - throw new Error('The managed Orca server pairing endpoint is not loopback-only.') - } - endpoint.hostname = '127.0.0.1' - endpoint.port = String(localPort) - return encodePairingOffer({ ...offer, endpoint: endpoint.toString() }) -} diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts index edd74796409..5d90c597e3c 100644 --- a/src/main/ssh/orcad-update-plan.test.ts +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -7,8 +7,6 @@ import { type OrcadStateSnapshot } from './orcad-activation-record' -const PROTOCOL = { protocolVersion: 3, previousProtocolVersions: [1, 2] } - const SNAPSHOT: OrcadStateSnapshot = { dirName: 'pre-0.2.0+bb01-1000', takenBeforeVersion: '0.2.0+bb01', @@ -30,20 +28,18 @@ function record(overrides: Partial = {}): OrcadActivation describe('planOrcadUpdate', () => { it('does nothing when the candidate is already active', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.2.0+bb01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 0, startedSinceActivation: 0 } }) expect(plan).toMatchObject({ action: 'noop' }) }) it('defers rather than restarting a host with live terminals', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 3, startedSinceActivation: 1, daemonProtocolVersion: 3 } + census: { liveSessions: 3, startedSinceActivation: 1 } }) expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_terminals_running' }) expect(plan.action === 'defer' && plan.reason).toContain('would not kill them') @@ -51,10 +47,9 @@ describe('planOrcadUpdate', () => { it('defers when the session count cannot be established', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 } + census: { liveSessions: null, startedSinceActivation: null } }) expect(plan).toMatchObject({ action: 'defer', @@ -64,10 +59,9 @@ describe('planOrcadUpdate', () => { it('plans a forced update with an unknown census as if terminals were live', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 }, + census: { liveSessions: null, startedSinceActivation: null }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -75,10 +69,9 @@ describe('planOrcadUpdate', () => { it('carries the daemon across a forced update with live terminals', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 2, startedSinceActivation: 0 }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -86,10 +79,9 @@ describe('planOrcadUpdate', () => { it('replaces the daemon only when nothing is running under it', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 0, startedSinceActivation: 0 } }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) }) @@ -98,10 +90,9 @@ describe('planOrcadUpdate', () => { describe('assessOrcadRollback', () => { it('is clean when the snapshot is intact and nothing happened since activation', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'clean', target: '0.1.0+aa01' }) @@ -109,10 +100,9 @@ describe('assessOrcadRollback', () => { it('is lossy, and names what goes, once the store has been written since activation', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 1, startedSinceActivation: 0 }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ safety: 'lossy', target: '0.1.0+aa01' }) @@ -121,10 +111,9 @@ describe('assessOrcadRollback', () => { it('treats an unreadable store mtime as writes, not as a clean rollback', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: null }) expect(safety).toMatchObject({ safety: 'lossy' }) @@ -133,10 +122,9 @@ describe('assessOrcadRollback', () => { // The point past which rollback is unsafe: the first terminal created after activation. it('refuses once a terminal started after activation, because restoring would orphan it', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 4, startedSinceActivation: 1, daemonProtocolVersion: 3 }, + census: { liveSessions: 4, startedSinceActivation: 1 }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ @@ -148,10 +136,9 @@ describe('assessOrcadRollback', () => { it('refuses when the snapshot the record names is gone from the host', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: false, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -160,10 +147,9 @@ describe('assessOrcadRollback', () => { it('refuses when no snapshot was ever recorded', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record({ snapshot: null }), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -171,10 +157,9 @@ describe('assessOrcadRollback', () => { it('refuses when the post-activation session count is unverifiable', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 2, startedSinceActivation: null, daemonProtocolVersion: 3 }, + census: { liveSessions: 2, startedSinceActivation: null }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_census_unavailable' }) @@ -182,83 +167,11 @@ describe('assessOrcadRollback', () => { it('refuses when there is no previous version to go back to', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record({ previous: null }), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_no_target' }) }) }) - -describe('D7 daemon protocol crossing', () => { - const census = (daemonProtocolVersion: number | null, liveSessions: number | null = 2) => ({ - liveSessions, - startedSinceActivation: 0, - daemonProtocolVersion - }) - - it.each([false, true])( - 'defers an update that would strand live terminals (force %s)', - (force) => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(4), - force - }) - expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_strands_live_terminals' }) - } - ) - - it('will not force past live terminals whose daemon protocol is unknown', () => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(null), - force: true - }) - expect(plan).toMatchObject({ - action: 'defer', - code: 'orcad_update_daemon_protocol_unverifiable' - }) - }) - - it('needs no protocol answer when no terminals are running', () => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(null, 0) - }) - expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) - }) - - it.each([ - [4, 'orcad_rollback_strands_live_terminals'], - [null, 'orcad_rollback_daemon_protocol_unverifiable'] - ])('refuses a rollback when the daemon speaks %s', (daemonProtocolVersion, code) => { - const safety = assessOrcadRollback({ - record: record(), - snapshotPresent: true, - census: census(daemonProtocolVersion), - targetDaemonProtocol: PROTOCOL, - stateWritesSinceActivation: false - }) - expect(safety).toMatchObject({ safety: 'unsafe', code }) - }) - - it('does not read an unverifiable snapshot probe as a missing snapshot', () => { - const safety = assessOrcadRollback({ - record: record(), - snapshotPresent: null, - census: census(3, 0), - targetDaemonProtocol: PROTOCOL, - stateWritesSinceActivation: false - }) - expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_unverifiable' }) - }) -}) diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts index 97c94acfdb1..2653956ebe5 100644 --- a/src/main/ssh/orcad-update-plan.ts +++ b/src/main/ssh/orcad-update-plan.ts @@ -19,13 +19,20 @@ * pre-activation snapshot rather than against a version comparison. */ import type { OrcadActivationRecord } from './orcad-activation-record' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' -import { - assessOrcadLiveDaemonCrossing, - type OrcadDaemonProtocolFacts -} from './orcad-daemon-protocol-crossing' -export type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' +export type OrcadTerminalCensus = { + /** + * Sessions the live daemon owns right now. `null` means the probe could not answer — + * never treated as zero, because loss of contact is not evidence of process death + * (docs/reference/ssh-execution-boundary.md). + */ + liveSessions: number | null + /** + * Of those, how many started at or after `record.activatedAt`. These are the sessions the + * pre-activation snapshot does not describe. + */ + startedSinceActivation: number | null +} export type OrcadUpdateDecision = | { action: 'noop'; reason: string } @@ -40,8 +47,6 @@ export type OrcadUpdateDecision = export type OrcadUpdateDeferCode = | 'orcad_update_terminals_running' | 'orcad_update_terminal_census_unavailable' - | 'orcad_update_strands_live_terminals' - | 'orcad_update_daemon_protocol_unverifiable' /** * Decide whether to restart orcad onto `candidateVersion`. @@ -55,8 +60,6 @@ export function planOrcadUpdate(input: { record: OrcadActivationRecord candidateVersion: string census: OrcadTerminalCensus - /** The candidate's daemon protocol and the older ones it can still attach to. */ - candidateDaemonProtocol: OrcadDaemonProtocolFacts force?: boolean }): OrcadUpdateDecision { if (input.record.active === input.candidateVersion) { @@ -65,29 +68,7 @@ export function planOrcadUpdate(input: { reason: `${input.candidateVersion} is already the active version; nothing to restart.` } } - const crossing = assessOrcadLiveDaemonCrossing(input.census, input.candidateDaemonProtocol) - // Why force cannot override: the operator can accept a mixed pair, not unreachable terminals. - if (crossing === 'strands-live-terminals') { - return { - action: 'defer', - code: 'orcad_update_strands_live_terminals', - reason: - `The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` + - `which orcad ${input.candidateVersion} cannot attach to. Restarting now would leave ` + - 'every running terminal unreachable. Update when no terminals are running.' - } - } const { liveSessions } = input.census - if (input.force && crossing === 'unverifiable') { - return { - action: 'defer', - code: 'orcad_update_daemon_protocol_unverifiable', - reason: - 'The terminal daemon did not report its protocol, so this update cannot show that ' + - `orcad ${input.candidateVersion} will reach the terminals it is forced past. Retry ` + - 'when the daemon answers.' - } - } if (liveSessions === null) { if (!input.force) { return { @@ -155,9 +136,6 @@ export type OrcadRollbackUnsafeCode = | 'orcad_rollback_snapshot_missing' | 'orcad_rollback_orphans_live_terminals' | 'orcad_rollback_census_unavailable' - | 'orcad_rollback_snapshot_unverifiable' - | 'orcad_rollback_strands_live_terminals' - | 'orcad_rollback_daemon_protocol_unverifiable' /** * How safe it is to switch back to `record.previous`. @@ -178,11 +156,9 @@ export type OrcadRollbackUnsafeCode = */ export function assessOrcadRollback(input: { record: OrcadActivationRecord - /** Whether the snapshot is still on the host; `null` means the probe was unverifiable. */ - snapshotPresent: boolean | null + /** Whether the snapshot named by the record is actually still on the host. */ + snapshotPresent: boolean census: OrcadTerminalCensus - /** The rollback target's daemon protocol facts, from the client's copy of its bytes. */ - targetDaemonProtocol: OrcadDaemonProtocolFacts /** * Whether the shared store has been written since activation, from its mtime against * `record.activatedAt`. `null` means unknown, which is treated as "yes" — claiming a @@ -200,15 +176,6 @@ export function assessOrcadRollback(input: { 'to. Deploy a known-good build instead.' } } - if (input.record.snapshot && input.snapshotPresent === null) { - return { - safety: 'unsafe', - code: 'orcad_rollback_snapshot_unverifiable', - reason: - 'The host did not give a trustworthy answer about the pre-activation snapshot. Retry ' + - 'when the host is reachable; loss of contact is not evidence the snapshot is gone.' - } - } if (!input.record.snapshot || !input.snapshotPresent) { return { safety: 'unsafe', @@ -242,26 +209,6 @@ export function assessOrcadRollback(input: { 'reattach. Close them (or let them exit) and roll back then.' } } - const crossing = assessOrcadLiveDaemonCrossing(input.census, input.targetDaemonProtocol) - if (crossing === 'unverifiable') { - return { - safety: 'unsafe', - code: 'orcad_rollback_daemon_protocol_unverifiable', - reason: - 'The terminal daemon did not report its protocol, so this rollback cannot show that ' + - `${target} would reach the terminals still running. Retry when the daemon answers.` - } - } - if (crossing === 'strands-live-terminals') { - return { - safety: 'unsafe', - code: 'orcad_rollback_strands_live_terminals', - reason: - `The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` + - `which ${target} does not list. Rolling back now would leave every running terminal ` + - 'unreachable. Roll back when no terminals are running, or deploy forward.' - } - } if (input.stateWritesSinceActivation === false) { return { safety: 'clean', diff --git a/src/main/ssh/runtime-ssh-access-verification.test.ts b/src/main/ssh/runtime-ssh-access-verification.test.ts deleted file mode 100644 index d847b801a23..00000000000 --- a/src/main/ssh/runtime-ssh-access-verification.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createEnvironmentFromPairingOffer } from '../../shared/runtime-environments' -import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' -import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' - -const send = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: send })) - -const pairing = { - v: 2 as const, - endpoint: 'wss://public.example/reverse-proxy/runtime', - deviceToken: 'existing-device-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - pairedDeviceId: 'existing-paired-client' -} -function environment() { - return createEnvironmentFromPairingOffer({ - id: 'environment', - name: 'Existing host', - now: 1, - offer: pairing, - runtimeId: 'host-runtime' - }) -} -function success() { - return { - id: 'status', - ok: true, - result: { - runtimeId: 'host-runtime', - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - deviceScope: 'runtime', - pairedDeviceId: 'existing-paired-client' - }, - _meta: { runtimeId: 'host-runtime' } - } -} - -describe('existing paired host verification over SSH', () => { - beforeEach(() => { - send.mockReset().mockResolvedValue(success()) - }) - - it('uses the native tunnel listener with the existing E2EE key and grant', async () => { - const original = environment() - const signal = new AbortController().signal - const result = await verifyRuntimeEnvironmentSshTunnel(original, 41000, signal) - expect(send).toHaveBeenCalledExactlyOnceWith( - { ...pairing, endpoint: 'ws://127.0.0.1:41000' }, - 'status.get', - undefined, - 15_000, - undefined, - signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - expect(result.verifiedRuntimeId).toBe('host-runtime') - expect(result.verifiedPairing).toEqual({ ...pairing, endpoint: 'ws://127.0.0.1:41000' }) - expect(original).toEqual(environment()) - }) - - it('can learn an unrecorded runtime id only after authenticated status verification', async () => { - const original = { ...environment(), runtimeId: null } - expect((await verifyRuntimeEnvironmentSshTunnel(original, 41000)).verifiedRuntimeId).toBe( - 'host-runtime' - ) - expect(original.runtimeId).toBeNull() - }) - - it.each([0, -1, 65536, 1.5, Number.NaN, Infinity])( - 'rejects invalid bound port %s before contact', - async (port) => { - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), port)).rejects.toThrow( - 'valid local port' - ) - expect(send).not.toHaveBeenCalled() - } - ) - - it.each(['host-identity', 'access-grant', 'connect'] as const)( - 'does not fallback when %s fails', - async (pairingStage) => { - const error = new RemoteRuntimeClientError('unauthorized', 'verification failed', { - pairingStage - }) - send.mockRejectedValue(error) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toBe(error) - expect(send).toHaveBeenCalledTimes(1) - } - ) - - it('refuses an RPC failure even when it carries the expected runtime metadata', async () => { - send.mockResolvedValue({ - id: 'status', - ok: false, - error: { code: 'unauthorized', message: 'Access revoked' }, - _meta: { runtimeId: 'host-runtime' } - }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow( - 'Access revoked' - ) - }) - - it.each([ - { runtimeId: 'other-host' }, - { pairedDeviceId: 'other-client' }, - { deviceScope: 'mobile' }, - { minCompatibleRuntimeClientVersion: RUNTIME_PROTOCOL_VERSION + 1 }, - { rendererGraphEpoch: -1 } - ])( - 'refuses wrong identity, grant scope, compatibility or malformed status: %j', - async (change) => { - const response = success() - send.mockResolvedValue({ ...response, result: { ...response.result, ...change } }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow() - } - ) - - it('refuses inconsistent runtime identity between status and its envelope', async () => { - send.mockResolvedValue({ ...success(), _meta: { runtimeId: 'different-runtime' } }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow( - 'runtime identity' - ) - }) - - it('accepts a compatible host that does not publish an optional paired device id', async () => { - const response = success() - send.mockResolvedValue({ - ...response, - result: { ...response.result, pairedDeviceId: undefined } - }) - expect((await verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).verifiedRuntimeId).toBe( - 'host-runtime' - ) - }) -}) diff --git a/src/main/ssh/runtime-ssh-access-verification.ts b/src/main/ssh/runtime-ssh-access-verification.ts deleted file mode 100644 index 9bf6058d1dd..00000000000 --- a/src/main/ssh/runtime-ssh-access-verification.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' -import { verifyRuntimeEnvironmentIdentity } from '../runtime/runtime-environment-identity-verification' -import type { PairingOffer } from '../../shared/pairing' -import type { RuntimeStatus } from '../../shared/runtime-types' - -export async function verifyRuntimeEnvironmentSshTunnel( - environment: KnownRuntimeEnvironment, - localPort: number, - signal?: AbortSignal -): Promise<{ - verifiedPairing: PairingOffer - verifiedRuntimeId: string - runtimeStatus: RuntimeStatus -}> { - if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65_535) { - throw new Error('The SSH tunnel did not provide a valid local port.') - } - // The tunnel targets the native listener, not the public reverse proxy; E2EE still pins its key. - return verifyRuntimeEnvironmentIdentity(environment, { - endpoint: `ws://127.0.0.1:${localPort}`, - signal - }) -} diff --git a/src/main/ssh/runtime-ssh-access.test.ts b/src/main/ssh/runtime-ssh-access.test.ts deleted file mode 100644 index 2a7cbee781b..00000000000 --- a/src/main/ssh/runtime-ssh-access.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { encodePairingOffer } from '../../shared/pairing' -import { - addEnvironmentFromPairingCode, - resolveEnvironment -} from '../../shared/runtime-environment-store' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { readPersistedEnvironmentStore } from '../../shared/runtime-environment-store-file' -import { writeRuntimeEnvironmentSidecarEntry } from '../../shared/runtime-environment-sidecar' -import type { SshTarget } from '../../shared/ssh-types' - -const mocks = vi.hoisted(() => ({ - infrastructure: vi.fn(), - connect: vi.fn(), - start: vi.fn(), - close: vi.fn(), - ensure: vi.fn(), - verify: vi.fn(), - flush: vi.fn(), - preflight: vi.fn(), - hasDirectAuthority: vi.fn() -})) -vi.mock('./orcad-managed-runtime-context', () => ({ - requireManagedOrcadInfrastructure: mocks.infrastructure -})) -vi.mock('./orcad-managed-tunnel', () => ({ - startOrcadManagedTunnel: mocks.start, - closeOrcadManagedTunnel: mocks.close, - ensureOrcadManagedTunnel: mocks.ensure -})) -vi.mock('./runtime-ssh-access-verification', () => ({ - verifyRuntimeEnvironmentSshTunnel: mocks.verify -})) -vi.mock('./ssh-target-registry', () => ({ - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority -})) -import { - fingerprintRuntimeSshTarget, - linkRuntimeSshAccess, - unlinkRuntimeSshAccess -} from './runtime-ssh-access' - -const pairing = { - v: 2 as const, - endpoint: 'wss://example.test/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'secret', - pairedDeviceId: 'client' -} - -describe('independent runtime SSH access coordinator', () => { - let userDataPath: string - let environmentId: string - let target: SshTarget - let events: string[] - const request = () => ({ - selector: environmentId, - requestId: 'request', - sshTargetId: 'target', - remotePort: 6768 - }) - const current = () => resolveEnvironment(userDataPath, environmentId) - const unlink = (requestId = 'unlink') => - unlinkRuntimeSshAccess( - userDataPath, - { selector: environmentId, requestId }, - { - invalidateTransport: () => { - events.push('invalidate') - expect(current().sshAccess).toBeUndefined() - } - } - ) - beforeEach(() => { - vi.resetAllMocks() - events = [] - userDataPath = mkdtempSync(join(tmpdir(), 'runtime-access-test-')) - environmentId = addEnvironmentFromPairingCode(userDataPath, { - name: 'server', - pairingCode: encodePairingOffer(pairing) - }).id - target = { - id: 'target', - label: 'host', - host: 'host.example', - port: 22, - username: 'user', - source: 'manual', - generation: 3 - } - mocks.preflight.mockReturnValue({ blockers: [] }) - mocks.flush.mockImplementation(async () => { - expect(current().pendingSshAccessOperation).toBeDefined() - events.push('flush') - }) - mocks.infrastructure.mockReturnValue({ - connectionManager: { connect: mocks.connect }, - claims: { - listTargets: () => [target], - preflight: mocks.preflight, - flush: mocks.flush, - claim: () => { - expect(current().pendingSshAccessOperation?.operation).toBe('link') - events.push('claim') - target = { ...target, owner: createManagedOrcadSshOwner(environmentId) } - return target - }, - release: () => { - expect(current().pendingSshAccessOperation?.operation).toBe('unlink') - events.push('release') - target = { ...target, owner: undefined } - return target - } - } - }) - mocks.connect.mockImplementation(async () => { - events.push('connect') - return {} - }) - mocks.start.mockImplementation(async () => { - events.push('tunnel') - return 41000 - }) - mocks.close.mockImplementation(async () => { - events.push('close') - }) - mocks.verify.mockImplementation(async () => { - events.push('verify') - return { - verifiedRuntimeId: 'runtime', - verifiedPairing: { ...pairing, endpoint: 'ws://127.0.0.1:41000' }, - runtimeStatus: {} - } - }) - }) - afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - - it.each(['link', 'unlink'])( - 'refuses %s before side effects during host reconciliation', - async (operation) => { - const environments = readPersistedEnvironmentStore(userDataPath).environments - const persisted = environments.find((entry) => entry.id === environmentId) - if (!persisted) { - throw new Error('missing environment') - } - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, persisted, { - reconciliation: { - version: 1, - stage: 'prepared', - requestId: 'reconciliation', - canonicalEnvironmentId: environmentId, - runtimeId: 'host-runtime', - preparedAt: 1, - registrations: [ - { environmentId, authorityDigest: 'a'.repeat(64) }, - { environmentId: 'peer', authorityDigest: 'b'.repeat(64) } - ] - } - }) - await expect( - operation === 'link' ? linkRuntimeSshAccess(userDataPath, request()) : unlink() - ).rejects.toThrow('reconciliation') - expect(mocks.connect).not.toHaveBeenCalled() - expect(mocks.start).not.toHaveBeenCalled() - expect(mocks.close).not.toHaveBeenCalled() - expect(mocks.flush).not.toHaveBeenCalled() - } - ) - - it('persists intent then claim before connecting and publishes redacted authenticated access', async () => { - const result = await linkRuntimeSshAccess(userDataPath, request()) - expect(events).toEqual(['flush', 'claim', 'flush', 'connect', 'tunnel', 'verify']) - expect(result.sshAccess?.sshTargetId).toBe('target') - expect(result.orcadDeployment).toBeUndefined() - expect(JSON.stringify(result)).not.toContain('secret') - expect(current().pendingSshAccessOperation).toBeUndefined() - expect(mocks.preflight).toHaveBeenCalledWith('target') - }) - - it('handles lost link responses without connecting or verifying again', async () => { - const first = await linkRuntimeSshAccess(userDataPath, request()) - expect(await linkRuntimeSshAccess(userDataPath, request())).toEqual(first) - expect(mocks.verify).toHaveBeenCalledTimes(1) - expect(mocks.ensure).toHaveBeenCalledWith(userDataPath, environmentId) - }) - - it('invalidates old transport only after authenticated access is published', async () => { - const invalidateTransport = vi.fn(() => { - expect(current().sshAccess).toBeDefined() - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - expect(invalidateTransport).toHaveBeenCalledTimes(2) - }) - - it('rejects changed request tuple after a completed link', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - await expect( - linkRuntimeSshAccess(userDataPath, { ...request(), remotePort: 6769 }) - ).rejects.toThrow('different') - expect(mocks.verify).toHaveBeenCalledTimes(1) - }) - - it('preserves the committed tunnel when transport refresh fails and retries the same link', async () => { - const invalidateTransport = vi.fn().mockRejectedValueOnce(new Error('refresh failed')) - await expect( - linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - ).rejects.toThrow('refresh failed') - const committed = current() - expect(committed.sshAccess?.requestId).toBe('request') - expect(committed.pendingSshAccessOperation).toBeUndefined() - expect(target.owner).toEqual(createManagedOrcadSshOwner(environmentId)) - expect(mocks.close).not.toHaveBeenCalled() - - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - expect(current()).toEqual(committed) - expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith(userDataPath, environmentId) - expect(mocks.connect).toHaveBeenCalledTimes(1) - expect(mocks.verify).toHaveBeenCalledTimes(1) - expect(mocks.start).toHaveBeenCalledTimes(1) - expect(invalidateTransport).toHaveBeenCalledTimes(2) - expect(mocks.close).not.toHaveBeenCalled() - }) - - it('fences connection configuration changes before opening a forward', async () => { - mocks.connect.mockImplementationOnce(async () => { - target = { ...target, username: 'other' } - return {} - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('changed') - expect(mocks.start).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation?.operation).toBe('link') - }) - - it('restores pairing before invalidating and closes before releasing the exact claim', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - events = [] - await unlink() - expect(events).toEqual(['invalidate', 'close', 'release', 'flush']) - expect(current().endpoints[0].endpoint).toBe(pairing.endpoint) - expect(current().sshAccess).toBeUndefined() - expect(target.owner).toBeUndefined() - }) - - it('retains claim and pending intent on wrong-host proof, then permits cancellation', async () => { - mocks.verify.mockRejectedValueOnce(new Error('wrong host')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('wrong host') - expect(current().pendingSshAccessOperation?.operation).toBe('link') - expect(current().sshAccess).toBeUndefined() - expect(target.owner).toBeDefined() - expect(mocks.close).toHaveBeenCalledTimes(1) - await unlink('request') - expect(target.owner).toBeUndefined() - }) - - it('retries verification failure with the same durable intent', async () => { - mocks.verify.mockRejectedValueOnce(new Error('unverifiable')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow() - await linkRuntimeSshAccess(userDataPath, request()) - expect(current().sshAccess).toBeDefined() - }) - - it.each(['host', 'generation', 'owner'] as const)( - 'fences %s reassignment during authenticated proof', - async (field) => { - mocks.verify.mockImplementationOnce(async () => { - target = { - ...target, - ...(field === 'host' - ? { host: 'elsewhere' } - : field === 'generation' - ? { generation: 4 } - : { owner: createManagedOrcadSshOwner('other') }) - } - return { verifiedRuntimeId: 'runtime', verifiedPairing: pairing } - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('changed') - expect(current().sshAccess).toBeUndefined() - expect(mocks.close).toHaveBeenCalled() - await expect(unlink('request')).rejects.toThrow('changed') - expect(events).not.toContain('release') - } - ) - - it.each([1, 2])('never connects when durable flush %s fails', async (flushNumber) => { - if (flushNumber === 2) { - mocks.flush.mockResolvedValueOnce(undefined) - } - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('disk full') - expect(mocks.connect).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation).toBeDefined() - expect(!!target.owner).toBe(flushNumber === 2) - }) - - it('resumes unlink after release flush failed without releasing another owner', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(unlink()).rejects.toThrow('disk full') - expect(target.owner).toBeUndefined() - await unlink() - expect(events.filter((event) => event === 'release')).toHaveLength(1) - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - - it('does not release a new owner when retrying an interrupted unlink', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(unlink()).rejects.toThrow('disk full') - target = { ...target, owner: createManagedOrcadSshOwner('other') } - await expect(unlink()).rejects.toThrow('changed') - expect(events.filter((event) => event === 'release')).toHaveLength(1) - expect(current().pendingSshAccessOperation?.operation).toBe('unlink') - }) - - it('refuses direct authority even on an already claimed retry', async () => { - mocks.verify.mockRejectedValueOnce(new Error('offline')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow() - mocks.preflight.mockReturnValue({ - blockers: [{ code: 'orcad_migration_direct_ssh_repositories' }] - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow( - 'direct SSH authority' - ) - expect(mocks.connect).toHaveBeenCalledTimes(1) - }) - - it('rejects provisioning and missing durable generations without connecting', async () => { - target.orcadProvisioning = { name: 'host', requestId: 'provision' } - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('provisioning') - target.orcadProvisioning = undefined - target.generation = undefined - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('generation') - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a connected direct provider even when it has no catalog or leases', async () => { - mocks.hasDirectAuthority.mockReturnValue(true) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow( - 'Disconnect direct SSH authority' - ) - expect(mocks.connect).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - - it('treats a lost successful unlink response as an idempotent no-op', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - const unlinked = await unlink() - const before = [...events] - expect(await unlink()).toEqual(unlinked) - expect(events).toEqual(before) - }) - - it('hashes connection identity, not display state or owner', () => { - const hash = fingerprintRuntimeSshTarget(target) - expect( - fingerprintRuntimeSshTarget({ - ...target, - label: 'renamed', - lastRequiredPassphrase: true, - owner: createManagedOrcadSshOwner('other') - }) - ).toBe(hash) - expect(fingerprintRuntimeSshTarget({ ...target, proxyCommand: 'other-proxy' })).not.toBe(hash) - }) - - it('strictly validates ports and request IDs', () => { - expect(() => linkRuntimeSshAccess(userDataPath, { ...request(), remotePort: 0 })).toThrow() - expect(() => - linkRuntimeSshAccess(userDataPath, { ...request(), requestId: '../other' }) - ).toThrow() - expect(mocks.infrastructure).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/runtime-ssh-access.ts b/src/main/ssh/runtime-ssh-access.ts deleted file mode 100644 index 6092a501052..00000000000 --- a/src/main/ssh/runtime-ssh-access.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { createHash } from 'node:crypto' -import { - RuntimeSshAccessLinkRequestSchema, - RuntimeSshAccessUnlinkRequestSchema, - type RuntimeSshAccessLinkRequest, - type RuntimeSshAccessUnlinkRequest -} from '../../shared/runtime-ssh-access' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { redactRuntimeEnvironment } from '../../shared/runtime-environments' -import { - cancelRuntimeEnvironmentSshAccessLink, - completeRuntimeEnvironmentSshAccessUnlink, - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink, - prepareRuntimeEnvironmentSshAccessUnlink -} from '../../shared/runtime-environment-ssh-access-store' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { requireManagedOrcadInfrastructure } from './orcad-managed-runtime-context' -import { - closeOrcadManagedTunnel, - ensureOrcadManagedTunnel, - startOrcadManagedTunnel -} from './orcad-managed-tunnel' -import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' - -type TargetClaims = ReturnType['claims'] - -export function fingerprintRuntimeSshTarget(target: SshTarget): string { - return createHash('sha256') - .update( - JSON.stringify({ - host: target.host, - port: target.port, - username: target.username, - configHost: target.configHost, - identityFile: target.identityFile, - identityAgent: target.identityAgent, - identitiesOnly: target.identitiesOnly, - gssapiAuthentication: target.gssapiAuthentication, - proxyCommand: target.proxyCommand, - jumpHost: target.jumpHost, - systemSshConnectionReuse: target.systemSshConnectionReuse - }) - ) - .digest('hex') -} - -function requireTarget(claims: TargetClaims, targetId: string): SshTarget & { generation: number } { - const target = claims.listTargets().find((entry) => entry.id === targetId) - const generation = target?.generation - if (!target || generation === undefined || !Number.isSafeInteger(generation) || generation <= 0) { - throw new Error('SSH access requires an existing durable SSH target generation.') - } - if (target.orcadProvisioning) { - throw new Error('This SSH target has pending server provisioning.') - } - return { ...target, generation } -} - -function requireAccessOnlyTarget(claims: TargetClaims, targetId: string, environmentId: string) { - const target = requireTarget(claims, targetId) - if (hasRegisteredDirectSshAuthority(targetId)) { - throw new Error('Disconnect direct SSH authority before linking access to this paired server.') - } - // Omitting the environment bypasses the same-owner shortcut and rechecks direct authority. - const blockers = claims - .preflight(targetId) - .blockers.filter( - (entry) => - !( - entry.code === 'orcad_migration_target_owned' && - getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId - ) - ) - if (blockers.length) { - throw new Error(`SSH access cannot claim direct SSH authority: ${blockers[0].code}`) - } - return target -} - -function requireFence( - claims: TargetClaims, - environmentId: string, - fence: { - sshTargetId: string - sshTargetGeneration: number - targetFingerprint?: string - }, - allowUnowned = false -): SshTarget { - const target = requireTarget(claims, fence.sshTargetId) - const owner = getManagedOrcadOwnerEnvironmentId(target.owner) - if ( - target.generation !== fence.sshTargetGeneration || - !fence.targetFingerprint || - fingerprintRuntimeSshTarget(target) !== fence.targetFingerprint || - (owner !== environmentId && !(allowUnowned && !target.owner)) - ) { - throw new Error('The SSH target registration, connection configuration, or owner changed.') - } - return target -} - -export function linkRuntimeSshAccess( - userDataPath: string, - input: RuntimeSshAccessLinkRequest, - options: { - signal?: AbortSignal - invalidateTransport?: (environmentId: string) => void | Promise - } = {} -) { - const args = RuntimeSshAccessLinkRequestSchema.parse(input) - const environmentId = resolveEnvironment(userDataPath, args.selector).id - return runTargetLifecycle(`runtime-ssh-access:${userDataPath}:${environmentId}`, () => - runTargetLifecycle(args.sshTargetId, async () => { - const { claims, connectionManager } = requireManagedOrcadInfrastructure() - const environment = resolveEnvironment(userDataPath, environmentId) - assertRuntimeEnvironmentNotReconciling(environment) - if (environment.orcadDeployment) { - throw new Error('Managed deployments cannot use independent SSH access.') - } - const target = requireAccessOnlyTarget(claims, args.sshTargetId, environmentId) - const fence = { - sshTargetId: target.id, - sshTargetGeneration: target.generation, - targetFingerprint: fingerprintRuntimeSshTarget(target) - } - if (environment.sshAccess) { - const access = environment.sshAccess - if ( - environment.pendingSshAccessOperation || - access.requestId !== args.requestId || - access.sshTargetId !== args.sshTargetId || - access.remotePort !== args.remotePort - ) { - throw new Error('This server already has a different SSH access request.') - } - requireFence(claims, environmentId, access) - await ensureOrcadManagedTunnel(userDataPath, environmentId) - requireFence(claims, environmentId, access) - await options.invalidateTransport?.(environmentId) - return redactRuntimeEnvironment(resolveEnvironment(userDataPath, environmentId)) - } - const prepared = prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId, - remotePort: args.remotePort, - ...fence - }) - await claims.flush() - requireAccessOnlyTarget(claims, target.id, environmentId) - requireFence(claims, environmentId, fence, true) - claims.claim(target.id, environmentId) - await claims.flush() - let tunnelStarted = false - let linkCommitted = false - try { - options.signal?.throwIfAborted() - const claimed = requireFence(claims, environmentId, fence) - const connection = await connectionManager.connect(claimed) - requireFence(claims, environmentId, fence) - tunnelStarted = true - const localPort = await startOrcadManagedTunnel( - environmentId, - claimed, - connection, - args.remotePort - ) - const proof = await verifyRuntimeEnvironmentSshTunnel(prepared, localPort, options.signal) - options.signal?.throwIfAborted() - requireAccessOnlyTarget(claims, target.id, environmentId) - requireFence(claims, environmentId, fence) - const linked = redactRuntimeEnvironment( - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: args.requestId, - ...proof, - tunnel: { - sshTargetId: target.id, - sshTargetGeneration: fence.sshTargetGeneration, - localPort, - remotePort: args.remotePort - } - }) - ) - linkCommitted = true - await options.invalidateTransport?.(environmentId) - return linked - } catch (error) { - if (tunnelStarted && !linkCommitted) { - await closeOrcadManagedTunnel(environmentId) - } - throw error - } - }) - ) -} - -export function unlinkRuntimeSshAccess( - userDataPath: string, - input: RuntimeSshAccessUnlinkRequest, - options: { invalidateTransport: (environmentId: string) => void | Promise } -) { - const args = RuntimeSshAccessUnlinkRequestSchema.parse(input) - const environmentId = resolveEnvironment(userDataPath, args.selector).id - return runTargetLifecycle(`runtime-ssh-access:${userDataPath}:${environmentId}`, async () => { - const environment = resolveEnvironment(userDataPath, environmentId) - assertRuntimeEnvironmentNotReconciling(environment) - if (environment.orcadDeployment) { - throw new Error('Managed deployments cannot unlink independent SSH access.') - } - const access = environment.pendingSshAccessOperation ?? environment.sshAccess - if (!access) { - return redactRuntimeEnvironment(environment) - } - return runTargetLifecycle(access.sshTargetId, async () => { - const { claims } = requireManagedOrcadInfrastructure() - requireFence(claims, environmentId, access, !!environment.pendingSshAccessOperation) - const prepared = - environment.pendingSshAccessOperation?.operation === 'link' - ? cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId - }) - : prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId - }) - await options.invalidateTransport(environmentId) - await closeOrcadManagedTunnel(environmentId) - const target = requireFence(claims, environmentId, access, true) - if (target.owner && !claims.release(target.id, environmentId)) { - throw new Error('The SSH target owner changed before access could be released.') - } - await claims.flush() - requireFence(claims, environmentId, access, true) - return redactRuntimeEnvironment( - completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: args.requestId - }) - ) - }) - }) -} diff --git a/src/main/ssh/ssh-channel-open.ts b/src/main/ssh/ssh-channel-open.ts deleted file mode 100644 index e46d2c28f62..00000000000 --- a/src/main/ssh/ssh-channel-open.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { trackSshConnectionChannelLifetime } from './ssh-connection-channel-lifetime' -import { CONNECT_TIMEOUT_MS, createSshOperationAbortError } from './ssh-connection-utils' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { isSshSessionLimitError } from './ssh-session-limit-error' - -type SshChannelOpenWork = ReturnType - -// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. -const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000 - -// Why: MaxSessions servers can transiently refuse a channel open; a refused open never ran the command, so retry is safe. -const SESSION_LIMIT_OPEN_RETRIES = 4 -const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150 - -export async function openSshSessionChannelWithRetry( - open: () => Promise, - signal?: AbortSignal -): Promise { - let lastError: unknown - for (let attempt = 0; attempt < SESSION_LIMIT_OPEN_RETRIES; attempt++) { - if (attempt > 0) { - // Why: an abort must release the backoff immediately, not after it. - if (!signal?.aborted) { - await new Promise((resolve) => { - const onDelayDone = (): void => { - clearTimeout(delayTimer) - signal?.removeEventListener('abort', onDelayDone) - resolve() - } - const delayTimer = setTimeout(onDelayDone, SESSION_LIMIT_OPEN_RETRY_DELAY_MS) - signal?.addEventListener('abort', onDelayDone, { once: true }) - }) - } - if (signal?.aborted) { - throw createSshOperationAbortError() - } - } - try { - return await open() - } catch (err) { - if (!isSshSessionLimitError(err)) { - throw err - } - lastError = err - } - } - throw lastError -} - -/** - * Waits for an ssh2 channel open, bounded by CONNECT_TIMEOUT_MS and an abort grace. The channel, - * late ones included, stays on `work` until it physically closes. - */ -export function waitForSshChannelOpen( - work: SshChannelOpenWork, - timeoutMessage: string, - register: (callback: (error: Error | undefined, value: T) => void) => void, - cleanupLateValue?: (value: T) => void, - signal?: AbortSignal, - trackRemoteCommandTermination = false, - onUnhandledError?: (error: Error) => void -): Promise { - return new Promise((resolve, reject) => { - type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } - let settled = false - let unconfirmedOpenError: ChannelOpenTerminationError | null = null - const markOpenUnconfirmed = (error: Error): Error => { - work.markUnverifiable(error) - if (!trackRemoteCommandTermination) { - return error - } - unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) - return unconfirmedOpenError - } - // Why: an in-flight open holds a MaxSessions slot; reject the caller now, then settle from the open callback once the late channel closes. - let abortRequested = false - let abortDeadlineTimer: NodeJS.Timeout | undefined - const cleanup = (): void => { - clearTimeout(timer) - clearTimeout(abortDeadlineTimer) - signal?.removeEventListener('abort', onAbort) - } - const onAbort = (): void => { - abortRequested = true - // Why: a hung socket may never invoke the open callback; bound the aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS. - abortDeadlineTimer = setTimeout(() => { - settled = true - cleanup() - reject(markOpenUnconfirmed(createSshOperationAbortError())) - }, ABORTED_CHANNEL_CLOSE_GRACE_MS) - } - const timer = setTimeout(() => { - settled = true - cleanup() - reject( - markOpenUnconfirmed( - abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) - ) - ) - }, CONNECT_TIMEOUT_MS) - const discardLateValue = (value: T, onClose?: () => void): void => { - const emitter = value as Partial & { - resume?: () => void - stderr?: Partial & { resume?: () => void } - } - const swallowLateError = (): void => {} - emitter.on?.('error', swallowLateError) - emitter.stderr?.on?.('error', swallowLateError) - if (onClose) { - emitter.once?.('close', onClose) - } - // Why: ssh2 withholds CHANNEL_CLOSE while discarded exec streams remain unread, and teardown errors have no other owner. - emitter.resume?.() - emitter.stderr?.resume?.() - try { - cleanupLateValue?.(value) - } catch { - /* best effort */ - } - } - const rejectAfterClose = (value: T): void => { - const abortError = markOpenUnconfirmed(createSshOperationAbortError()) - const emitter = value as Partial & { - resume?: () => void - stderr?: { resume?: () => void } - } - let finished = false - const done = (): void => { - if (finished) { - return - } - finished = true - clearTimeout(closeGraceTimer) - emitter.removeListener?.('close', confirmAndDone) - reject(abortError) - } - const confirmAndDone = (): void => { - if (unconfirmedOpenError === abortError) { - unconfirmedOpenError.sshChannelCloseConfirmed = true - } - done() - } - // Why: bounded so a remote that never confirms the close can't hang the aborted operation forever. - const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) - if (typeof emitter.once === 'function') { - emitter.once('close', confirmAndDone) - } - // Why: ssh2 withholds 'close' until the channel's streams are drained; nobody else will read this discarded channel. - discardLateValue(value) - if (typeof emitter.once !== 'function') { - done() - } - } - const finish = (error: Error | undefined, value?: T): void => { - // Late channels stay tracked until physical close, after the caller has given up. - if (!error && value !== undefined) { - trackSshConnectionChannelLifetime(work, value, onUnhandledError) - } else { - work.close(error) - } - if (settled) { - // Why: ssh2 can invoke the open callback after our timeout rejected; close that late channel so it isn't left open with no owner. - if (!error && value !== undefined) { - discardLateValue(value, () => { - if (unconfirmedOpenError) { - unconfirmedOpenError.sshChannelCloseConfirmed = true - } - }) - } - return - } - settled = true - cleanup() - if (abortRequested) { - if (!error && value !== undefined) { - rejectAfterClose(value) - } else { - reject(createSshOperationAbortError()) - } - return - } - if (error) { - reject(error) - return - } - resolve(value as T) - } - if (signal?.aborted) { - // No open is in flight yet, so failing fast leaks nothing. - cleanup() - work.close() - reject(createSshOperationAbortError()) - return - } - signal?.addEventListener('abort', onAbort, { once: true }) - - try { - // Why: higher-level channel timers start only after ssh2's open callback; a stale SSH socket can otherwise keep exec/sftp stuck. - register(finish) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - } - }) -} diff --git a/src/main/ssh/ssh-connection-channel-error.test.ts b/src/main/ssh/ssh-connection-channel-error.test.ts deleted file mode 100644 index 8ecede18213..00000000000 --- a/src/main/ssh/ssh-connection-channel-error.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness' -import * as sshClientMock from './__tests__/ssh-connection-test-client' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.restoreAllMocks() -}) - -it('logs an exec channel error nothing else handles, naming the target and channel kind', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.execBehavior = 'pending' - const opening = conn.exec('true') - await vi.waitFor(() => expect(sshClientMock.pendingExecCallback).not.toBeNull()) - const channel = Object.assign(new EventEmitter(), { close: vi.fn() }) - sshClientMock.pendingExecCallback?.(undefined, channel) - await opening - - expect(() => channel.emit('error', new Error('channel reset'))).not.toThrow() - expect(warn).toHaveBeenCalledWith( - '[ssh] Unhandled exec channel error for Test Server: channel reset' - ) - await conn.disconnect() -}) - -it('does not log a forwarded channel error its owner handles', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - const owner = vi.fn() - conn.forwardStreamLocal(client, '/owned.sock', () => channel.on('error', owner)) - - channel.emit('error', new Error('handled')) - expect(owner).toHaveBeenCalledOnce() - expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Unhandled')) - await conn.disconnect() -}) diff --git a/src/main/ssh/ssh-connection-channel-lifetime.test.ts b/src/main/ssh/ssh-connection-channel-lifetime.test.ts deleted file mode 100644 index 82371f36880..00000000000 --- a/src/main/ssh/ssh-connection-channel-lifetime.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { trackSshConnectionChannelLifetime } from './ssh-connection-channel-lifetime' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -const signal = () => new AbortController().signal - -it('settles the opening only when the channel emits close', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - const fence = ledger.fenceForReset() - channel.emit('end') - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - channel.emit('close') - await fence.drain(signal()) - expect(channel.listenerCount('error')).toBe(0) -}) - -it('treats an already closed channel as settled', async () => { - const ledger = new SshConnectionWorkLedger() - trackSshConnectionChannelLifetime( - ledger.beginChannelOpen(), - Object.assign(new EventEmitter(), { closed: true }) - ) - await ledger.fenceForReset().drain(signal()) -}) - -it('keeps a fenced channel error unverifiable even after it closes', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - const fence = ledger.fenceForReset() - const failure = new Error('channel write failed') - channel.emit('error', failure) - channel.emit('close') - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('does not carry a pre-fence error from a channel that closed before the fence', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - channel.emit('error', new Error('closed before reset')) - channel.emit('close') - await ledger.fenceForReset().drain(signal()) -}) - -it.each([undefined, null, {}, { on: () => {} }])( - 'marks an untrackable open result (%s) unverifiable instead of settled', - async (value) => { - const ledger = new SshConnectionWorkLedger() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), value) - await expect(ledger.fenceForReset().drain(signal())).rejects.toThrow( - 'ssh_connection_channel_lifetime_unverifiable' - ) - } -) - -it('runs the injected admission check before admitting work', () => { - const ledger = new SshConnectionWorkLedger(undefined, () => { - throw new Error('connection disposed') - }) - expect(() => ledger.beginChannelOpen()).toThrow('connection disposed') -}) - -it('reports a channel error that nothing else handles instead of hiding it', () => { - const report = vi.fn() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime( - new SshConnectionWorkLedger().beginChannelOpen(), - channel, - report - ) - const failure = new Error('channel reset') - expect(() => channel.emit('error', failure)).not.toThrow() - expect(report).toHaveBeenCalledWith(failure) -}) - -it('leaves a channel error to the owner that handles it', () => { - const report = vi.fn() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime( - new SshConnectionWorkLedger().beginChannelOpen(), - channel, - report - ) - const owner = vi.fn() - channel.on('error', owner) - channel.emit('error', new Error('handled')) - expect(owner).toHaveBeenCalledOnce() - expect(report).not.toHaveBeenCalled() -}) - -it('logs an unhandled channel error with the [ssh] prefix when no reporter is given', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(new SshConnectionWorkLedger().beginChannelOpen(), channel) - channel.emit('error', new Error('orphaned')) - expect(warn).toHaveBeenCalledWith('[ssh] Unhandled SSH channel error: orphaned') - warn.mockRestore() -}) diff --git a/src/main/ssh/ssh-connection-channel-lifetime.ts b/src/main/ssh/ssh-connection-channel-lifetime.ts deleted file mode 100644 index b09a7d288c9..00000000000 --- a/src/main/ssh/ssh-connection-channel-lifetime.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { - SshConnectionWorkChannel, - SshConnectionWorkLedger -} from './ssh-connection-work-ledger' - -type TrackableChannel = SshConnectionWorkChannel & { - closed?: unknown -} - -function isTrackableChannel(value: unknown): value is TrackableChannel { - if (typeof value !== 'object' || value === null) { - return false - } - return ( - 'on' in value && - typeof value.on === 'function' && - 'once' in value && - typeof value.once === 'function' && - 'removeListener' in value && - typeof value.removeListener === 'function' - ) -} - -export type SshChannelErrorReporter = (error: Error) => void - -const reportOrphanChannelError: SshChannelErrorReporter = (error) => { - console.warn(`[ssh] Unhandled SSH channel error: ${error.message}`) -} - -// The tracker's own listener must not be what keeps an error from surfacing. -function hasOnlyTrackerErrorListener(channel: TrackableChannel): boolean { - return ( - !('listenerCount' in channel) || - typeof channel.listenerCount !== 'function' || - channel.listenerCount('error') <= 1 - ) -} - -export function openTrackedSshSocket( - ledger: SshConnectionWorkLedger, - open: () => T, - onUnhandledError?: SshChannelErrorReporter -): T { - const work = ledger.beginChannelOpen() - try { - const socket = open() - trackSshConnectionChannelLifetime(work, socket, onUnhandledError) - return socket - } catch (error) { - work.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} - -/** Start tracking before the open callback hands the channel to another owner. */ -export function trackSshConnectionChannelLifetime( - work: ReturnType, - value: unknown, - onUnhandledError: SshChannelErrorReporter = reportOrphanChannelError -): void { - if (!isTrackableChannel(value)) { - work.markUnverifiable(new Error('ssh_connection_channel_lifetime_unverifiable')) - return - } - const channel = value - work.bind(channel) - if (channel.closed === true) { - work.close() - return - } - const onError = (error: Error) => { - work.markUnverifiable(error) - if (hasOnlyTrackerErrorListener(channel)) { - onUnhandledError(error) - } - } - channel.on('error', onError) - channel.once('close', () => { - channel.removeListener('error', onError) - work.close() - }) -} diff --git a/src/main/ssh/ssh-connection-channel-open.test.ts b/src/main/ssh/ssh-connection-channel-open.test.ts index 58d4a5633b2..f0f6454af64 100644 --- a/src/main/ssh/ssh-connection-channel-open.test.ts +++ b/src/main/ssh/ssh-connection-channel-open.test.ts @@ -286,7 +286,7 @@ describe('SshConnection', () => { await expect(outcomePromise).resolves.toBe('AbortError') }) - it('removes the abort-grace waiter while retaining physical-close tracking', async () => { + it('removes the late-channel close listener when abort grace expires', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() vi.useFakeTimers() @@ -302,14 +302,12 @@ describe('SshConnection', () => { await vi.advanceTimersByTimeAsync(0) controller.abort() pendingSftpCallback?.(undefined, lateSftp) - expect(lateSftp.listenerCount('close')).toBe(2) + expect(lateSftp.listenerCount('close')).toBe(1) expect(() => lateSftp.emit('error', new Error('late SFTP teardown'))).not.toThrow() await vi.advanceTimersByTimeAsync(5_000) await expect(outcomePromise).resolves.toBe('AbortError') - expect(lateSftp.listenerCount('close')).toBe(1) - lateSftp.emit('close') expect(lateSftp.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() @@ -378,7 +376,7 @@ describe('SshConnection', () => { expect(lateSftp.end).toHaveBeenCalledTimes(1) }) - it('retains SFTP physical-close tracking after the bounded observation expires', async () => { + it('removes the late SFTP close listener when the bounded grace expires', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() ssh2Mock.sftpBehavior = 'pending' @@ -395,13 +393,11 @@ describe('SshConnection', () => { await Promise.resolve() controller.abort() pendingSftpCallback?.(undefined, lateSftp) - expect(lateSftp.listenerCount('close')).toBe(2) + expect(lateSftp.listenerCount('close')).toBe(1) await vi.advanceTimersByTimeAsync(5_000) await expect(outcomePromise).resolves.toBe('AbortError') - expect(lateSftp.listenerCount('close')).toBe(1) - lateSftp.emit('close') expect(lateSftp.listenerCount('close')).toBe(0) expect(lateSftp.end).toHaveBeenCalledTimes(1) } finally { diff --git a/src/main/ssh/ssh-connection-close-drain.ts b/src/main/ssh/ssh-connection-close-drain.ts deleted file mode 100644 index e905a62ceaa..00000000000 --- a/src/main/ssh/ssh-connection-close-drain.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { EventEmitter } from 'node:events' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import type { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import { observeSshTransportClose } from './ssh-transport-close-observation' - -type WorkFence = ReturnType - -/** - * Disconnects, then waits for the live transport resources, every client ever allocated and all - * fenced work to close. Listeners go on before close starts so a fast 'close' is not missed. - */ -export async function disconnectAndAwaitSshTransportClose(options: { - liveResources: readonly EventEmitter[] - disconnect: () => Promise - transportCloseLedger: SshTransportCloseLedger - fence: WorkFence - signal: AbortSignal -}): Promise { - const observation = observeSshTransportClose(options.liveResources) - const waiting = new AbortController() - const waitSignal = AbortSignal.any([options.signal, waiting.signal]) - try { - await options.disconnect() - await Promise.all([ - observation.wait(waitSignal), - options.transportCloseLedger.drain(waitSignal), - options.fence.drain(waitSignal) - ]) - options.fence.assertDrained() - } finally { - waiting.abort() - observation.dispose() - } -} diff --git a/src/main/ssh/ssh-connection-destination-capture.test.ts b/src/main/ssh/ssh-connection-destination-capture.test.ts deleted file mode 100644 index 6f0ce04a808..00000000000 --- a/src/main/ssh/ssh-connection-destination-capture.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createHash } from 'node:crypto' -import { beforeEach, expect, it, vi } from 'vitest' -import { - clientInstances, - nextSshClientCreation, - resetSshConnectionMocks, - resolveWithSshGMock, - ssh2Mock, - VALID_ED25519_HOST_KEY -} from './ssh-connection-test-harness' -import { createCallbacks, createTarget, createResolvedConfig } from './ssh-connection-test-fixtures' -import { SshConnection } from './ssh-connection' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(() => { - resetSshConnectionMocks() -}) - -it('captures only the successful endpoint and accepted key, not the saved alias', async () => { - resolveWithSshGMock.mockResolvedValue( - createResolvedConfig({ - hostname: 'resolved.example', - port: 2222, - user: 'resolved-user', - proxyUseFdpass: false - }) - ) - const conn = new SshConnection(createTarget({ configHost: 'alias' }), createCallbacks()) - expect(conn.getExecutionDestination()).toBeUndefined() - try { - await conn.connect() - const destination = conn.getExecutionDestination() - expect(destination).toEqual({ - version: 1, - transport: 'ssh2', - host: 'resolved.example', - port: 2222, - username: 'resolved-user', - hostKeyFingerprint: `SHA256:${createHash('sha256').update(VALID_ED25519_HOST_KEY).digest('base64').replace(/=+$/, '')}`, - proxyRouteDigest: createHash('sha256').update('null').digest('hex') - }) - expect(Object.isFrozen(destination)).toBe(true) - expect(conn.getExecutionDestination()).toBe(destination) - } finally { - await conn.disconnect() - } - expect(conn.getExecutionDestination()).toBeUndefined() -}) - -it('does not publish an accepted key until authentication succeeds', async () => { - ssh2Mock.connectBehavior = 'pending' - const conn = new SshConnection(createTarget(), createCallbacks()) - const created = nextSshClientCreation() - const connecting = conn.connect() - await created - expect(conn.getExecutionDestination()).toBeUndefined() - const rejected = expect(connecting).rejects.toThrow() - await conn.disconnect() - await rejected - clientInstances[0].emit('ready') - expect(conn.getExecutionDestination()).toBeUndefined() -}) - -it('invalidates identity immediately on transport loss', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await conn.connect() - expect(conn.getExecutionDestination()).toBeDefined() - clientInstances[0].emit('end') - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('does not publish an accepted key from failed authentication', async () => { - ssh2Mock.connectBehavior = 'error' - ssh2Mock.connectErrorMessage = 'Authentication failed' - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await expect(conn.connect()).rejects.toThrow() - expect(ssh2Mock.lastHostKeyAccepted).toBe(true) - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('never publishes identity from a rejected host key', async () => { - ssh2Mock.presentedHostKey = Buffer.from('invalid host key') - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await expect(conn.connect()).rejects.toThrow() - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('does not treat a system SSH probe as command-channel destination evidence', async () => { - const conn = new SshConnection(createTarget({ configHost: 'alias' }), createCallbacks()) - resolveWithSshGMock.mockResolvedValue(createResolvedConfig({ proxyUseFdpass: true })) - try { - await conn.connect() - expect(conn.usesSystemSshTransport()).toBe(true) - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) diff --git a/src/main/ssh/ssh-connection-destination.test.ts b/src/main/ssh/ssh-connection-destination.test.ts deleted file mode 100644 index 7240113e108..00000000000 --- a/src/main/ssh/ssh-connection-destination.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseSshConnectionDestination } from './ssh-connection-destination' - -const destination = { - version: 1, - transport: 'ssh2', - host: 'resolved.example', - port: 2222, - username: 'owner', - hostKeyFingerprint: `SHA256:${Buffer.alloc(32, 255).toString('base64').replace(/=+$/, '')}`, - proxyRouteDigest: 'a'.repeat(64) -} - -describe('parseSshConnectionDestination', () => { - it('makes an immutable canonical snapshot without normalizing identity', () => { - const input = { ...destination, host: 'MixedCase.example', ignored: true } - const saved = parseSshConnectionDestination(input) - input.host = 'another.example' - expect(saved).toEqual({ ...destination, host: 'MixedCase.example' }) - expect(Object.isFrozen(saved)).toBe(true) - expect(parseSshConnectionDestination(JSON.parse(JSON.stringify(saved)))).toEqual(saved) - }) - - it.each([ - { version: 2 }, - { transport: 'system-ssh' }, - { host: '' }, - { host: 'host\nother' }, - { host: 'h'.repeat(8193) }, - { username: '' }, - { username: 'user\0name' }, - { username: 3 }, - { port: 0 }, - { port: 65536 }, - { port: 22.5 }, - { port: '22' }, - { port: Number.NaN }, - { hostKeyFingerprint: 'SHA256:short' }, - { hostKeyFingerprint: `SHA256:${'A'.repeat(43)}=` }, - { hostKeyFingerprint: `SHA256:${'A'.repeat(42)}B` }, - { proxyRouteDigest: 'A'.repeat(64) }, - { proxyRouteDigest: 'a'.repeat(63) }, - { proxyRouteDigest: undefined } - ])('refuses malformed binding %j', (change) => { - expect(() => parseSshConnectionDestination({ ...destination, ...change })).toThrow( - 'ssh_connection_destination_invalid' - ) - }) - - it.each([null, undefined, [], 'host', 1])('refuses nonrecords %j', (value) => { - expect(() => parseSshConnectionDestination(value)).toThrow('ssh_connection_destination_invalid') - }) - - it.each([1, 65535])('accepts port boundary %i', (port) => { - expect(parseSshConnectionDestination({ ...destination, port }).port).toBe(port) - }) -}) diff --git a/src/main/ssh/ssh-connection-destination.ts b/src/main/ssh/ssh-connection-destination.ts deleted file mode 100644 index 49bbbe8545a..00000000000 --- a/src/main/ssh/ssh-connection-destination.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createHash } from 'node:crypto' - -/** The execution host a successful ssh2 handshake proved: endpoint, accepted key and proxy route. */ -export type SshConnectionDestination = Readonly<{ - version: 1 - transport: 'ssh2' - host: string - port: number - username: string - hostKeyFingerprint: string - proxyRouteDigest: string -}> - -/** Digest of the resolved proxy route; a changed ProxyCommand/ProxyJump names a different path. */ -export function sshProxyRouteDigest(effectiveProxy: unknown): string { - return createHash('sha256') - .update(JSON.stringify(effectiveProxy ?? null)) - .digest('hex') -} - -function isValidText(text: unknown): text is string { - return ( - typeof text === 'string' && - text.length > 0 && - text.length <= 8192 && - !Array.from(text).some( - (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 - ) - ) -} - -function isCanonicalSha256Fingerprint(value: unknown): value is string { - if (typeof value !== 'string' || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(value)) { - return false - } - const encoded = value.slice(7) - // Why: a non-canonical trailing digit decodes to the same bytes but names a different string. - return ( - Buffer.from(encoded, 'base64').toString('base64url') === - encoded.replace(/\+/g, '-').replace(/\//g, '_') - ) -} - -export function parseSshConnectionDestination(value: unknown): SshConnectionDestination { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error('ssh_connection_destination_invalid') - } - const record: Record = { ...value } - const { host, port, username, hostKeyFingerprint, proxyRouteDigest } = record - if ( - record.version !== 1 || - record.transport !== 'ssh2' || - !isValidText(host) || - typeof port !== 'number' || - !Number.isSafeInteger(port) || - port < 1 || - port > 65535 || - !isValidText(username) || - !isCanonicalSha256Fingerprint(hostKeyFingerprint) || - typeof proxyRouteDigest !== 'string' || - !/^[a-f0-9]{64}$/.test(proxyRouteDigest) - ) { - throw new Error('ssh_connection_destination_invalid') - } - return Object.freeze({ - version: 1, - transport: 'ssh2', - host, - port, - username, - hostKeyFingerprint, - proxyRouteDigest - }) -} diff --git a/src/main/ssh/ssh-connection-direct-startup.test.ts b/src/main/ssh/ssh-connection-direct-startup.test.ts index 09bb39e653b..a2344930eea 100644 --- a/src/main/ssh/ssh-connection-direct-startup.test.ts +++ b/src/main/ssh/ssh-connection-direct-startup.test.ts @@ -56,16 +56,12 @@ describe('SshConnection', () => { await connect expect(channel.close).toHaveBeenCalled() expect(channel.listenerCount('data')).toBe(0) - // Lifetime observers remain until physical close, even after the probe timed out. - expect(channel.listenerCount('error')).toBe(2) - expect(channel.listenerCount('close')).toBe(2) + expect(channel.listenerCount('error')).toBe(1) + expect(channel.listenerCount('close')).toBe(1) expect(channel.stderr.listenerCount('data')).toBe(0) expect( (conn as unknown as { systemCommandChannels: Set }).systemCommandChannels.size ).toBe(0) - channel.emit('close', 0) - expect(channel.listenerCount('close')).toBe(0) - expect(channel.listenerCount('error')).toBe(1) } finally { vi.useRealTimers() } diff --git a/src/main/ssh/ssh-connection-disconnect-drain.test.ts b/src/main/ssh/ssh-connection-disconnect-drain.test.ts deleted file mode 100644 index 4b04eae0e9a..00000000000 --- a/src/main/ssh/ssh-connection-disconnect-drain.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { SshConnectionManager } from './ssh-connection-manager' -import { assertManagerTargetTransportsClosed } from './ssh-connection-manager-test-probes' -import { createCallbacks, createResolvedConfig, createTarget } from './ssh-connection-test-fixtures' -import { clientInstances, resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -async function fixture(conn = new SshConnection(createTarget(), createCallbacks())) { - await conn.connect() - const client = conn.getClient()! - // The shared SSH mock omits once/removeListener; retain its lifecycle handlers alongside real events. - const observed = new EventEmitter() - const emit = client.emit.bind(client) - Object.assign(client, { - once: observed.once.bind(observed), - removeListener: observed.removeListener.bind(observed), - listenerCount: observed.listenerCount.bind(observed), - emit: (event: string, ...args: unknown[]) => { - emit(event, ...args) - return observed.emit(event, ...args) - } - }) - const end = vi.spyOn(client, 'end').mockImplementation(() => client) - const run = (signal = new AbortController().signal) => conn.disconnectAndDrain(signal) - const forward = () => { - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - conn.forwardStreamLocal(client, '/owned.sock', vi.fn()) - return channel - } - return { conn, client, end, run, forward } -} - -it('waits for failed startup sockets even after another attempt connects successfully', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(conn.connect()).rejects.toThrow('startup refused') - const failed = clientInstances[0]! - ssh2Mock.destroyErrorMessage = '' - const f = await fixture(conn) - const done = vi.fn() - const draining = f.run().then(done) - f.client.emit('close') - // Let all resolved drain promises settle; the failed socket remains open. - await new Promise((resolve) => setImmediate(resolve)) - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() -}) - -it('drains a settled failed exclusive startup only after its socket physically closes', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(conn.connect()).rejects.toThrow('startup refused') - const failed = clientInstances[0]! - const done = vi.fn() - const draining = conn.disconnectAndDrain(new AbortController().signal).then(done) - await new Promise((resolve) => setImmediate(resolve)) - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - await expect(conn.connect()).rejects.toThrow('ssh_connection_reset_replacement_refused') -}) - -it('remembers failed socket closure across an early ordinary disconnect', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - await expect(conn.connect()).rejects.toThrow('startup refused') - await conn.disconnect() - await conn.disconnectAndDrain(new AbortController().signal) -}) - -it('allows a fenced exclusive connection with no allocations to finish cleanup', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - await conn.disconnectAndDrain(new AbortController().signal) - await expect(conn.connect()).rejects.toThrow('ssh_connection_reset_replacement_refused') -}) - -it('refuses drain while configuration can still allocate a startup transport', async () => { - const resolved = Promise.withResolvers() - vi.mocked(resolveWithSshG).mockReturnValueOnce(resolved.promise) - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const connecting = conn.connect().catch((error: unknown) => error) - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow( - 'ssh_connection_close_transport_unproven' - ) - resolved.resolve(null) - expect(await connecting).toBeInstanceOf(Error) - await conn.disconnectAndDrain(new AbortController().signal) - expect(clientInstances).toHaveLength(0) -}) - -it('does not treat logical disconnection as physical client closure', async () => { - const f = await fixture() - const done = vi.fn() - const draining = f.run().then(done) - await Promise.resolve() - expect(f.end).toHaveBeenCalledOnce() - expect(f.conn.getState().status).toBe('disconnected') - expect(done).not.toHaveBeenCalled() - f.client.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() -}) - -it.each(['client', 'channel'] as const)( - 'requires both physical client and channel close when %s closes first', - async (first) => { - const f = await fixture() - const channel = f.forward() - const done = vi.fn() - const draining = f.run().then(done) - ;(first === 'client' ? f.client : channel).emit('close') - await Promise.resolve() - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - ;(first === 'client' ? channel : f.client).emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - } -) - -it('subscribes before disconnect can synchronously close the client', async () => { - const f = await fixture() - f.end.mockImplementation(() => { - f.client.emit('close') - return f.client - }) - await f.run() - expect(f.end).toHaveBeenCalledOnce() -}) - -it('aborts waiting for physical close and removes its listener', async () => { - const f = await fixture() - const before = f.client.listenerCount('close') - const controller = new AbortController() - const draining = f.run(controller.signal) - expect(f.client.listenerCount('close')).toBeGreaterThan(before) - controller.abort() - await expect(draining).rejects.toThrow() - expect(f.client.listenerCount('close')).toBe(before) - expect(f.conn.getClient()).toBeNull() -}) - -it('pre-aborted admission does not close the connection', async () => { - const f = await fixture() - await expect(f.run(AbortSignal.abort())).rejects.toThrow() - expect(f.end).not.toHaveBeenCalled() - await f.conn.disconnect() -}) - -it('aborts unresolved channel drain even after the physical client closes', async () => { - const f = await fixture() - const channel = f.forward() - const controller = new AbortController() - const draining = f.run(controller.signal) - f.client.emit('close') - controller.abort() - await expect(draining).rejects.toThrow() - expect(f.client.listenerCount('close')).toBe(0) - channel.emit('close') -}) - -it('cleans owned overlapping clients but refuses unproven drain', async () => { - const f = await fixture() - const pending = { end: vi.fn(), destroy: vi.fn() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private pending-client set. - const clients = (f.conn as unknown as { pendingSsh2Clients: Set }).pendingSsh2Clients - clients.add(pending) - await expect(f.run()).rejects.toThrow() - expect(f.end).toHaveBeenCalledOnce() - expect(pending.destroy).toHaveBeenCalledOnce() - clients.delete(pending) - await f.conn.disconnect() -}) - -it('waits for the owned proxy close as well as client close', async () => { - const f = await fixture() - const proxy = Object.assign(new EventEmitter(), { kill: vi.fn() }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installs a stub proxy process into SshConnection's private field. - ;(f.conn as unknown as { proxyProcess: typeof proxy }).proxyProcess = proxy - const done = vi.fn() - const draining = f.run().then(done) - expect(proxy.kill).toHaveBeenCalledOnce() - f.client.emit('close') - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - proxy.emit('close') - await draining -}) - -it('never schedules reconnect when a drain closes its client', async () => { - const f = await fixture() - vi.useFakeTimers() - const draining = f.run() - f.client.emit('close') - await draining - await vi.advanceTimersByTimeAsync(60_000) - expect(f.conn.getClient()).toBeNull() - expect(f.conn.getState().reconnectAttempt).toBe(0) - expect(vi.getTimerCount()).toBe(0) -}) - -it('refuses a disconnected connection without claiming drain proof', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow() -}) - -it('retains ordinary manager transport debt after pool removal until physical close', async () => { - const manager = new SshConnectionManager(createCallbacks()) - const target = createTarget() - const conn = await manager.connect(target) - const client = conn.getClient()! - await manager.disconnect(target.id) - expect(manager.getConnection(target.id)).toBeUndefined() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - client.emit('close') - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('cleans failed ordinary startup without mistaking destroy for physical close', async () => { - const manager = new SshConnectionManager(createCallbacks()) - const target = createTarget() - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(manager.connect(target)).rejects.toThrow('startup refused') - expect(manager.getConnection(target.id)).toBeUndefined() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - clientInstances[0]!.emit('close') - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('closes owned system SSH but refuses unproven drain', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow() - expect(conn.getState().status).toBe('disconnected') - await conn.disconnect() -}) diff --git a/src/main/ssh/ssh-connection-file-transfer.test.ts b/src/main/ssh/ssh-connection-file-transfer.test.ts index 610d24c08d9..0d2e830d6bb 100644 --- a/src/main/ssh/ssh-connection-file-transfer.test.ts +++ b/src/main/ssh/ssh-connection-file-transfer.test.ts @@ -182,6 +182,11 @@ describe('SshConnection', () => { await expect( uploadSession.uploadFile('/tmp/late.txt', '/remote/late.txt') ).rejects.toMatchObject({ name: 'AbortError' }) - expect(uploadFileViaSystemSsh).not.toHaveBeenCalled() + expect(uploadFileViaSystemSsh).toHaveBeenCalledWith( + expect.anything(), + '/tmp/late.txt', + '/remote/late.txt', + expect.objectContaining({ signal: expect.objectContaining({ aborted: true }) }) + ) }) }) diff --git a/src/main/ssh/ssh-connection-file-transfers.ts b/src/main/ssh/ssh-connection-file-transfers.ts deleted file mode 100644 index c9cc36dcdd7..00000000000 --- a/src/main/ssh/ssh-connection-file-transfers.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import type { SshTarget } from '../../shared/ssh-types' -import type { FileUploadSession } from '../providers/types' -import { - resolveSftpTransferPathIfMapped, - type SftpNamespacePathMapping -} from './sftp-namespace-resolution' -import { - createLinkedSshFileTransferSignal, - raceSftpFileTransferWithAbort -} from './ssh-file-transfer-abort' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { - downloadFileViaSystemSsh, - uploadDirectoryViaSystemSsh, - uploadFileViaSystemSsh, - writeBufferViaSystemSsh, - writeFileViaSystemSsh, - type SystemSshBuildArgsOptions -} from './ssh-system-fallback' - -export type SshRemoteFileOptions = { - hostPlatform?: RemoteHostPlatform - // Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch. - sftpNamespace?: SftpNamespacePathMapping -} - -/** What a transfer reads from its connection; getters so each read sees the live transport. */ -export type SshFileTransferHost = { - target: SshTarget - usesSystemSshTransport: () => boolean - systemOperationSignal: () => AbortSignal - systemSshBuildArgsOptions: () => SystemSshBuildArgsOptions - sftp: (signal?: AbortSignal) => Promise -} - -export async function uploadSshDirectory( - host: SshFileTransferHost, - localDir: string, - remoteDir: string, - options?: SshRemoteFileOptions & { signal?: AbortSignal } -): Promise { - // Why: relay-deploy timeout and connection teardown are independent owners; either must stop a transfer that could outlive its lock. - const linkedSignal = createLinkedSshFileTransferSignal( - [host.systemOperationSignal(), options?.signal].filter( - (signal): signal is AbortSignal => signal !== undefined - ) - ) - try { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp(linkedSignal.signal) - const swallowLateSftpError = (): void => {} - let sftpEndRequested = false - const endSftp = (): void => { - if (!sftpEndRequested) { - sftpEndRequested = true - sftp.end() - } - } - sftp.on('error', swallowLateSftpError) - sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) - try { - // Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace. - const transfer = (async (): Promise => { - const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) - linkedSignal.signal.throwIfAborted() - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') - await uploadDirectory(sftp, localDir, targetDir, localDir, { - signal: linkedSignal.signal - }) - })() - await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { - sftp.once('close', onClose) - endSftp() - return () => sftp.removeListener('close', onClose) - }) - } finally { - endSftp() - } - return - } - await uploadDirectoryViaSystemSsh(host.target, localDir, remoteDir, { - signal: linkedSignal.signal, - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) - } finally { - linkedSignal.dispose() - } -} - -export async function downloadSshFile( - host: SshFileTransferHost, - remotePath: string, - localPath: string, - options?: SshRemoteFileOptions -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - try { - const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp') - await fastGetViaSftp(sftp, remotePath, localPath) - } finally { - sftp.end() - } - return - } - await downloadFileViaSystemSsh(host.target, remotePath, localPath, { - signal: host.systemOperationSignal(), - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) -} - -export async function createSshFileUploadSession( - host: SshFileTransferHost, - options?: SshRemoteFileOptions -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - const { uploadFile } = await import('./sftp-upload') - return { - uploadFile: (localPath, remotePath, uploadOptions) => - uploadFile(sftp, localPath, remotePath, uploadOptions), - close: () => sftp.end() - } - } - // Why: disconnect replaces the connection controller, so an existing import session must stay bound to the signal and SSH config it opened with. - const signal = host.systemOperationSignal() - const buildArgsOptions = host.systemSshBuildArgsOptions() - return { - uploadFile: (localPath, remotePath, uploadOptions) => - uploadFileViaSystemSsh(host.target, localPath, remotePath, { - signal, - hostPlatform: options?.hostPlatform, - exclusive: uploadOptions?.exclusive, - ...buildArgsOptions - }), - close: () => {} - } -} - -export async function writeSshFile( - host: SshFileTransferHost, - remotePath: string, - contents: string, - options?: SshRemoteFileOptions & { signal?: AbortSignal } -): Promise { - // Keep package/version writes under the same dual cancellation contract as uploads. - const linkedSignal = createLinkedSshFileTransferSignal( - [host.systemOperationSignal(), options?.signal].filter( - (signal): signal is AbortSignal => signal !== undefined - ) - ) - try { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp(linkedSignal.signal) - const swallowLateSftpError = (): void => {} - let sftpEndRequested = false - const endSftp = (): void => { - if (!sftpEndRequested) { - sftpEndRequested = true - sftp.end() - } - } - sftp.on('error', swallowLateSftpError) - sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) - try { - // Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace. - const write = (async (): Promise => { - const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) - linkedSignal.signal.throwIfAborted() - const { writeStringViaSftp } = await import('./sftp-upload') - await writeStringViaSftp(sftp, targetPath, contents) - })() - await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { - sftp.once('close', onClose) - endSftp() - return () => sftp.removeListener('close', onClose) - }) - } finally { - endSftp() - } - return - } - await writeFileViaSystemSsh(host.target, remotePath, contents, { - signal: linkedSignal.signal, - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) - } finally { - linkedSignal.dispose() - } -} - -export async function writeSshBuffer( - host: SshFileTransferHost, - remotePath: string, - contents: Buffer, - options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean } -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - try { - const { uploadBuffer } = await import('./sftp-upload') - await uploadBuffer(sftp, contents, remotePath, options) - } finally { - sftp.end() - } - return - } - await writeBufferViaSystemSsh(host.target, remotePath, contents, { - signal: host.systemOperationSignal(), - hostPlatform: options?.hostPlatform, - append: options?.append, - exclusive: options?.exclusive, - ...host.systemSshBuildArgsOptions() - }) -} diff --git a/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts b/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts index 68d3b6ec19a..9dd1e6d22a2 100644 --- a/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts +++ b/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts @@ -16,7 +16,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' // Type-only, so it is erased before vi.mock's hoisted factory runs. import type * as SshConfigParser from './ssh-config-parser' -import type * as Ssh2 from 'ssh2' const VALID_ED25519_HOST_KEY = Buffer.from( 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', @@ -32,8 +31,8 @@ let eventHandlers: Map void>> let presentedHostKey: Buffer let hostKeyAccepted: boolean | undefined -vi.mock('ssh2', async (importOriginal) => { - const { utils } = await importOriginal() +vi.mock('ssh2', () => { + const utils = { parseKey: vi.fn(() => new Error('parse failed')) } class MockSshClient { setNoDelay = vi.fn() _sock: Socket | undefined = new Socket() diff --git a/src/main/ssh/ssh-connection-manager-closure.test.ts b/src/main/ssh/ssh-connection-manager-closure.test.ts deleted file mode 100644 index 79e456b1653..00000000000 --- a/src/main/ssh/ssh-connection-manager-closure.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - assertManagerTargetTransportsClosed, - managerTargetActive, - managerUnclosedTransportTargets -} from './ssh-connection-manager-test-probes' -import { beforeEach, expect, it, vi } from 'vitest' -import type { SshTarget } from '../../shared/ssh-types' - -type ClosureMockState = { - clients: { close: () => void; status: string }[] - connectError?: Error - disconnectError?: Error -} - -const state = vi.hoisted((): ClosureMockState => ({ clients: [] })) -vi.mock('./ssh-connection', () => ({ - SshConnection: class { - status = 'connected' - close = () => {} - constructor() { - state.clients.push(this) - } - subscribeTransportClosure(callback: () => void) { - this.close = callback - return () => {} - } - async connect() { - if (state.connectError) { - throw state.connectError - } - } - async disconnect() { - if (state.disconnectError) { - throw state.disconnectError - } - this.status = 'disconnected' - } - async disconnectAndDrain() { - await this.disconnect() - this.close() - } - getState() { - return { status: this.status } - } - setCallbacks() {} - } -})) -import { SshConnectionManager } from './ssh-connection-manager' - -const target: SshTarget = { - id: 'owned', - label: 'Owned', - host: 'example.test', - port: 22, - username: 'deploy' -} -beforeEach(() => { - state.clients.length = 0 - state.connectError = undefined - state.disconnectError = undefined -}) - -it('retains failed startup transport evidence until its disposed connection physically closes', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - state.connectError = new Error('authentication rejected') - await expect(manager.connect(target)).rejects.toThrow('authentication rejected') - expect(state.clients[0].status).toBe('disconnected') - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(false) - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - state.clients[0].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('requires every allocation and ignores duplicate close notifications', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - await manager.disconnect(target.id) - await manager.connect(target) - await manager.disconnect(target.id) - state.clients[0].close() - state.clients[0].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - state.clients[1].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('does not let an old close remove a replacement or taint an unrelated target', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - await manager.disconnect(target.id) - const replacement = await manager.connect(target) - state.clients[0].close() - expect(manager.getConnection(target.id)).toBe(replacement) - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow() - expect(() => assertManagerTargetTransportsClosed(manager, 'unrelated')).not.toThrow() -}) - -it('releases bookkeeping after repeated observed closure', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - for (let index = 0; index < 100; index++) { - await manager.connect(target) - await manager.disconnect(target.id) - state.clients[index].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() - } - expect(managerUnclosedTransportTargets(manager)).toBe(0) -}) diff --git a/src/main/ssh/ssh-connection-manager-registry.test.ts b/src/main/ssh/ssh-connection-manager-registry.test.ts index a7b2b06e805..e6a6bc24843 100644 --- a/src/main/ssh/ssh-connection-manager-registry.test.ts +++ b/src/main/ssh/ssh-connection-manager-registry.test.ts @@ -88,57 +88,4 @@ describe('SshConnectionManager', () => { expect(mgr.getConnection('a')).toBeUndefined() expect(mgr.getConnection('b')).toBeUndefined() }) - - it('disconnectAll preserves excluded reset connections in the registry', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const retained = await mgr.connect(createTarget({ id: 'reset' })) - await mgr.connect(createTarget({ id: 'ordinary' })) - const disconnect = vi.spyOn(retained, 'disconnect') - await mgr.disconnectAll((id) => id !== 'reset') - expect(disconnect).not.toHaveBeenCalled() - expect(mgr.getConnection('reset')).toBe(retained) - expect(mgr.getConnection('ordinary')).toBeUndefined() - await mgr.disconnectAll() - }) - - it('checks disconnect admission immediately before each transport effect', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const first = await mgr.connect(createTarget({ id: 'first' })) - const second = await mgr.connect(createTarget({ id: 'second' })) - const disconnectSecond = vi.spyOn(second, 'disconnect') - let reserved = false - const originalDisconnect = first.disconnect.bind(first) - vi.spyOn(first, 'disconnect').mockImplementation(() => { - reserved = true - return originalDisconnect() - }) - await mgr.disconnectAll((id) => id !== 'second' || !reserved) - expect(disconnectSecond).not.toHaveBeenCalled() - expect(mgr.getConnection('second')).toBe(second) - await mgr.disconnectAll() - }) - - it('does not erase a replacement registered while an admitted disconnect settles', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const target = createTarget({ id: 'target' }) - const old = await mgr.connect(target) - const state = old.getState() - vi.spyOn(old, 'getState').mockReturnValue({ ...state, status: 'disconnected' }) - let finish!: () => void - vi.spyOn(old, 'disconnect') - .mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - .mockResolvedValue(undefined) - const drain = mgr.disconnectAll() - const replacement = await mgr.connect(target) - expect(replacement).not.toBe(old) - finish() - await drain - expect(mgr.getConnection('target')).toBe(replacement) - await mgr.disconnectAll() - }) }) diff --git a/src/main/ssh/ssh-connection-manager-test-probes.ts b/src/main/ssh/ssh-connection-manager-test-probes.ts deleted file mode 100644 index 3c85210d596..00000000000 --- a/src/main/ssh/ssh-connection-manager-test-probes.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { SshConnectionManager } from './ssh-connection-manager' - -type ManagerBookkeeping = { - connections: Map - connectingTargets: Map - pendingTargetOperations: Map - unconfirmedTargetTeardowns: Set - unclosedTransportsByTarget: Map -} - -function bookkeeping(manager: SshConnectionManager): ManagerBookkeeping { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are SshConnectionManager's own private fields; tests read them until T3/T8 expose readers. - return manager as unknown as ManagerBookkeeping -} - -/** Test-side reading of the target activity T3's hasTargetActivity will expose. */ -export function managerTargetActive(manager: SshConnectionManager, targetId: string): boolean { - const state = bookkeeping(manager) - return ( - state.connections.has(targetId) || - state.connectingTargets.has(targetId) || - state.unconfirmedTargetTeardowns.has(targetId) || - state.pendingTargetOperations.has(targetId) - ) -} - -/** Test-side reading of the closure proof T8's assertTargetTransportsClosed will expose. */ -export function assertManagerTargetTransportsClosed( - manager: SshConnectionManager, - targetId: string -): void { - if ( - managerTargetActive(manager, targetId) || - bookkeeping(manager).unclosedTransportsByTarget.has(targetId) - ) { - throw new Error('ssh_target_transport_closure_unproven') - } -} - -/** How many targets still owe a physical transport close. */ -export function managerUnclosedTransportTargets(manager: SshConnectionManager): number { - return bookkeeping(manager).unclosedTransportsByTarget.size -} diff --git a/src/main/ssh/ssh-connection-manager.test.ts b/src/main/ssh/ssh-connection-manager.test.ts index b5c61574fec..b2de497a006 100644 --- a/src/main/ssh/ssh-connection-manager.test.ts +++ b/src/main/ssh/ssh-connection-manager.test.ts @@ -1,4 +1,3 @@ -import { managerTargetActive } from './ssh-connection-manager-test-probes' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshTarget } from '../../shared/ssh-types' @@ -13,7 +12,6 @@ const mockState = vi.hoisted(() => ({ vi.mock('./ssh-connection', () => ({ SshConnection: class MockSshConnection { - subscribeTransportClosure = vi.fn(() => () => {}) status: 'connecting' | 'connected' | 'disconnected' = 'connecting' connect = vi.fn(async () => { await (mockState.connectResults.shift() ?? Promise.resolve()) @@ -22,7 +20,6 @@ vi.mock('./ssh-connection', () => ({ disconnect = vi.fn(async () => { this.status = 'disconnected' }) - reconnect = vi.fn(async () => {}) constructor() { mockState.instances.push(this) @@ -48,53 +45,6 @@ const target = { } as SshTarget describe('SshConnectionManager', () => { - it('reports invalidated pending attempts even after disconnect removes the registration', async () => { - let reject!: (error: Error) => void - mockState.connectResults.push( - new Promise((_resolve, fail) => { - reject = fail - }) - ) - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - const pending = manager.connect(target) - const rejected = expect(pending).rejects.toThrow('cancelled') - await manager.disconnect(target.id) - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(true) - expect(managerTargetActive(manager, 'unrelated')).toBe(false) - reject(new Error('cancelled')) - await rejected - expect(managerTargetActive(manager, target.id)).toBe(false) - }) - - it('tracks detached exact-connection teardown until its promise settles', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - const conn = await manager.connect(target) - await manager.disconnect(target.id) - let finish!: () => void - mockState.instances[0].disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const pending = manager.disconnectConnection(target.id, conn) - expect(managerTargetActive(manager, target.id)).toBe(true) - finish() - await pending - expect(managerTargetActive(manager, target.id)).toBe(false) - }) - - it('retains uncertainty after bulk teardown fails and removes the registration', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - mockState.instances[0].disconnect.mockRejectedValueOnce(new Error('close unconfirmed')) - await manager.disconnectAll() - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(true) - expect(managerTargetActive(manager, 'unrelated')).toBe(false) - }) - beforeEach(() => { mockState.connectResults.length = 0 mockState.instances.length = 0 diff --git a/src/main/ssh/ssh-connection-manager.ts b/src/main/ssh/ssh-connection-manager.ts index aad5bdde561..e553bf906b5 100644 --- a/src/main/ssh/ssh-connection-manager.ts +++ b/src/main/ssh/ssh-connection-manager.ts @@ -6,46 +6,17 @@ import { SshConnection, type SshConnectionCallbacks } from './ssh-connection' // 300-line oxlint max-lines threshold while preserving a clear // single-responsibility boundary (connection lifecycle vs. pool management). -// Bounds an owned drain so a host that never confirms close cannot hang its caller. -const OWNED_CONNECTION_DRAIN_TIMEOUT_MS = 10_000 - export class SshConnectionManager { private connections = new Map() private callbacks: SshConnectionCallbacks // Why: attempt identity lets disconnect unblock a replacement without the // cancelled attempt later clearing the replacement's state. private connectingTargets = new Map() - // Local operation and teardown bookkeeping; never a verdict on remote process exit. - private pendingTargetOperations = new Map() - private unconfirmedTargetTeardowns = new Set() - // Counts every connection a target allocated, retired pool entries included, until it closes. - private readonly unclosedTransportsByTarget = new Map() constructor(callbacks: SshConnectionCallbacks) { this.callbacks = callbacks } - private registerConnection(targetId: string, connection: SshConnection): void { - this.unclosedTransportsByTarget.set( - targetId, - (this.unclosedTransportsByTarget.get(targetId) ?? 0) + 1 - ) - let observed = false - connection.subscribeTransportClosure(() => { - if (observed) { - return - } - observed = true - const remaining = (this.unclosedTransportsByTarget.get(targetId) ?? 1) - 1 - if (remaining === 0) { - this.unclosedTransportsByTarget.delete(targetId) - } else { - this.unclosedTransportsByTarget.set(targetId, remaining) - } - }) - this.connections.set(targetId, connection) - } - setCallbacks(callbacks: SshConnectionCallbacks): void { this.callbacks = callbacks for (const connection of this.connections.values()) { @@ -54,10 +25,6 @@ export class SshConnectionManager { } async connect(target: SshTarget): Promise { - return this.trackTargetOperation(target.id, () => this.connectTarget(target)) - } - - private async connectTarget(target: SshTarget): Promise { const existing = this.connections.get(target.id) if (existing?.getState().status === 'connected') { return existing @@ -76,16 +43,13 @@ export class SshConnectionManager { } const conn = new SshConnection(target, this.callbacks) - this.registerConnection(target.id, conn) + this.connections.set(target.id, conn) try { await conn.connect() } catch (err) { - // Why: a failed startup can still hold sockets, so it is disconnected, not just forgotten. - try { - await this.disconnectConnection(target.id, conn) - } catch (cleanupError) { - throw new AggregateError([err, cleanupError], 'ssh_connection_startup_cleanup_failed') + if (this.connections.get(target.id) === conn) { + this.connections.delete(target.id) } throw err } @@ -99,29 +63,6 @@ export class SshConnectionManager { } async disconnect(targetId: string): Promise { - return this.trackTargetOperation(targetId, () => this.disconnectTarget(targetId), true) - } - - /** Drains only the registered connection for this target; local closure is not remote exit. */ - async disconnectAndDrain(targetId: string, signal: AbortSignal): Promise { - return this.trackTargetOperation( - targetId, - async () => { - this.connectingTargets.delete(targetId) - const conn = this.connections.get(targetId) - if (!conn) { - return - } - await conn.disconnectAndDrain(signal) - if (this.connections.get(targetId) === conn) { - this.connections.delete(targetId) - } - }, - true - ) - } - - private async disconnectTarget(targetId: string): Promise { // Why: disconnect invalidates the old attempt immediately so a reconnect // need not wait for the cancelled socket's late completion. this.connectingTargets.delete(targetId) @@ -140,19 +81,11 @@ export class SshConnectionManager { * Why: a cancelled connect whose transport opened late owns that exact connection — disconnecting * by target id would tear down the replacement's live transport instead. */ - async disconnectConnection(targetId: string, conn: SshConnection, drain = false): Promise { - return this.trackTargetOperation( - targetId, - async () => { - await (drain - ? conn.disconnectAndDrain(AbortSignal.timeout(OWNED_CONNECTION_DRAIN_TIMEOUT_MS)) - : conn.disconnect()) - if (this.connections.get(targetId) === conn) { - this.connections.delete(targetId) - } - }, - true - ) + async disconnectConnection(targetId: string, conn: SshConnection): Promise { + await conn.disconnect() + if (this.connections.get(targetId) === conn) { + this.connections.delete(targetId) + } } async reconnect(targetId: string): Promise { @@ -160,33 +93,7 @@ export class SshConnectionManager { if (!conn) { return } - await this.trackTargetOperation(targetId, () => conn.reconnect()) - } - - private async trackTargetOperation( - targetId: string, - operation: () => Promise, - retainFailure = false - ): Promise { - this.pendingTargetOperations.set( - targetId, - (this.pendingTargetOperations.get(targetId) ?? 0) + 1 - ) - try { - return await operation() - } catch (error) { - if (retainFailure) { - this.unconfirmedTargetTeardowns.add(targetId) - } - throw error - } finally { - const remaining = (this.pendingTargetOperations.get(targetId) ?? 1) - 1 - if (remaining === 0) { - this.pendingTargetOperations.delete(targetId) - } else { - this.pendingTargetOperations.set(targetId, remaining) - } - } + await conn.reconnect() } getConnection(targetId: string): SshConnection | undefined { @@ -205,27 +112,9 @@ export class SshConnectionManager { return states } - async disconnectAll(shouldDisconnect: (targetId: string) => boolean = () => true): Promise { - await Promise.allSettled( - Array.from(this.connections).map(async ([targetId, connection]) => { - if (!shouldDisconnect(targetId)) { - return - } - await this.trackTargetOperation( - targetId, - async () => { - try { - await connection.disconnect() - } finally { - // A later registration or an excluded target is not this drain's to remove. - if (this.connections.get(targetId) === connection) { - this.connections.delete(targetId) - } - } - }, - true - ) - }) - ) + async disconnectAll(): Promise { + const disconnects = Array.from(this.connections.values()).map((c) => c.disconnect()) + await Promise.allSettled(disconnects) + this.connections.clear() } } diff --git a/src/main/ssh/ssh-connection-reconnect-ladder.test.ts b/src/main/ssh/ssh-connection-reconnect-ladder.test.ts index 42d61b4d92d..d62856611ba 100644 --- a/src/main/ssh/ssh-connection-reconnect-ladder.test.ts +++ b/src/main/ssh/ssh-connection-reconnect-ladder.test.ts @@ -256,41 +256,6 @@ describe('SshConnection', () => { } }) - it('recovers the same owner after a temporary DNS resolution failure', async () => { - vi.useFakeTimers() - try { - const states: string[] = [] - const conn = new SshConnection( - createTarget(), - createCallbacks({ - onStateChange: vi.fn((_id, state) => states.push(state.status)) - }) - ) - await connectWithFakeTimers(conn) - const connectedGeneration = conn.getTransportGeneration() - const dnsFailure = Object.assign( - new Error('getaddrinfo EAI_AGAIN temporary failure in name resolution'), - { code: 'EAI_AGAIN' } - ) - ssh2Mock.connectSequence = [dnsFailure, 'ready'] - - emitSshEvent('close') - await advanceToNextSshClient(RECONNECT_BACKOFF_MS[0]) - - expect(conn.getState().status).toBe('reconnecting') - expect(states).not.toContain('error') - expect(states).not.toContain('reconnection-failed') - - await advanceToNextSshClient(RECONNECT_BACKOFF_MS[1]) - - expect(conn.getState().status).toBe('connected') - expect(conn.getTransportGeneration()).toBeGreaterThan(connectedGeneration) - expect(clientInstances).toHaveLength(3) - } finally { - vi.useRealTimers() - } - }) - it('keeps a system-transport target on the ladder after a probe timeout', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts b/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts deleted file mode 100644 index 261c4288aa1..00000000000 --- a/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { isRuntimeOwnedSshTarget, SshConnectionStore } from './ssh-connection-store' -import { createMockStore } from './ssh-connection-store-test-fixture' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ - loadUserSshConfigMock: vi.fn(), - sshConfigHostsToTargetsMock: vi.fn() -})) - -vi.mock('./ssh-config-parser', () => ({ - loadUserSshConfig: loadUserSshConfigMock, - sshConfigHostsToTargets: sshConfigHostsToTargetsMock -})) - -const base = { label: 'cluster', host: 'cluster.example.com', port: 22, username: 'dev' } - -describe('managed orcad ownership of SSH targets', () => { - let mockStore: ReturnType - let sshStore: SshConnectionStore - - beforeEach(() => { - mockStore = Object.assign(createMockStore(), emptyDependentStateStore()) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements the store methods SshConnectionStore calls. - sshStore = new SshConnectionStore(mockStore as never) - loadUserSshConfigMock.mockReset() - sshConfigHostsToTargetsMock.mockReset() - }) - - it('hides claimed and provisioning targets from direct SSH lists', () => { - const visible = sshStore.addTarget(base) - sshStore.addTarget({ - ...base, - label: 'provisioning', - orcadProvisioning: { requestId: 'r', name: 'n' } - }) - const claimed = sshStore.addTarget({ ...base, label: 'claimed' }) - sshStore.getOrcadRuntimeClaims().claim(claimed.id, 'environment-1') - - expect(sshStore.listTargets()).toEqual([visible]) - expect(sshStore.getOrcadRuntimeClaims().listTargets()).toHaveLength(3) - }) - - it('keeps ~/.ssh/config sync from rewriting a claimed config-sourced host', () => { - mockStore.addSshTarget({ - ...base, - id: 'ssh-config-host', - configHost: 'cluster', - source: 'ssh-config', - owner: createManagedOrcadSshOwner('environment-1') - }) - loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) - sshConfigHostsToTargetsMock.mockReturnValue([ - { ...base, id: 'tmp', configHost: 'cluster', host: '10.0.0.9', port: 2222 } - ]) - - expect(sshStore.importFromSshConfig()).toEqual([]) - expect(mockStore.updateSshTarget).not.toHaveBeenCalled() - }) - - it('treats ownership or a provisioning intent as runtime-owned', () => { - const target = { ...base, id: 'ssh-1' } - expect(isRuntimeOwnedSshTarget(target)).toBe(false) - expect( - isRuntimeOwnedSshTarget({ ...target, orcadProvisioning: { requestId: 'r', name: 'n' } }) - ).toBe(true) - expect(isRuntimeOwnedSshTarget({ ...target, owner: createManagedOrcadSshOwner('e') })).toBe( - true - ) - }) -}) diff --git a/src/main/ssh/ssh-connection-store-test-fixture.ts b/src/main/ssh/ssh-connection-store-test-fixture.ts deleted file mode 100644 index 1908112a8ed..00000000000 --- a/src/main/ssh/ssh-connection-store-test-fixture.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { vi } from 'vitest' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { Repo } from '../../shared/repo-types' -import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' - -export function createMockStore() { - const targets: SshTarget[] = [] - const repos: Repo[] = [] - const folderWorkspaces: FolderWorkspace[] = [] - const projectGroups: { id: string; name: string; connectionId?: string | null }[] = [] - const leases: { - state: 'attached' | 'detached' | 'terminated' | 'expired' - ptyId?: string - worktreeId?: string - tabId?: string - leafId?: string - updatedAt?: number - }[] = [] - let deletedAliases: string[] = [] - const removedTombstones: RemovedSshTargetTombstone[] = [] - const reassignments: { oldTargetId: string; newTargetId: string }[] = [] - let generationCounter = 0 - - const dropTombstone = (oldTargetId: string) => { - const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) - removedTombstones.length = 0 - removedTombstones.push(...kept) - } - - return { - allocateSshTargetGeneration: vi.fn(() => { - generationCounter += 1 - return generationCounter - }), - getSshTargets: vi.fn(() => [...targets]), - getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)), - getRepos: vi.fn(() => [...repos]), - getProjectGroups: vi.fn(() => [...projectGroups]), - getFolderWorkspaces: vi.fn(() => [...folderWorkspaces]), - getSshRemotePtyLeases: vi.fn(() => [...leases]), - inspectOrcadMigrationSourceDependencies: vi.fn(() => ({ - totalCount: 0, - counts: { - automation: 0, - 'automation-run': 0, - 'mobile-tab-selection': 0, - 'retired-worktree-name': 0, - 'saved-port-forward': 0, - 'sparse-preset': 0, - 'terminal-lease': 0, - 'terminal-recovery': 0, - 'ui-routing': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - 'worktree-lineage': 0, - 'worktree-metadata': 0 - } - })), - addSshTarget: vi.fn((target: SshTarget) => targets.push(target)), - updateSshTarget: vi.fn((id: string, updates: Partial>) => { - const target = targets.find((t) => t.id === id) - if (!target) { - return null - } - Object.assign(target, updates) - return { ...target } - }), - removeSshTarget: vi.fn((id: string) => { - const idx = targets.findIndex((t) => t.id === id) - if (idx !== -1) { - targets.splice(idx, 1) - } - }), - getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), - addDeletedSshConfigAlias: vi.fn((alias: string) => { - if (!deletedAliases.includes(alias)) { - deletedAliases.push(alias) - } - }), - removeDeletedSshConfigAlias: vi.fn((alias: string) => { - deletedAliases = deletedAliases.filter((entry) => entry !== alias) - }), - clearDeletedSshConfigAliases: vi.fn(() => { - deletedAliases = [] - }), - removedTombstones, - reassignments, - repos, - projectGroups, - folderWorkspaces, - leases, - getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]), - addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => { - const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId) - removedTombstones.length = 0 - removedTombstones.push(...filtered, tombstone) - }), - removeRemovedSshTargetTombstone: vi.fn(dropTombstone), - // Nothing in this mock stores automations, so releasing always drops. - releaseRemovedSshTargetTombstone: vi.fn(dropTombstone), - reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { - reassignments.push({ oldTargetId, newTargetId }) - // Pretend one repo referenced the old id. - return ['repo-1'] - }) - } -} diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index fc2b39e88af..24845d2d373 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshConnectionStore } from './ssh-connection-store' -import { createMockStore } from './ssh-connection-store-test-fixture' -import type { SshTarget } from '../../shared/ssh-types' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ loadUserSshConfigMock: vi.fn(), @@ -13,6 +12,72 @@ vi.mock('./ssh-config-parser', () => ({ sshConfigHostsToTargets: sshConfigHostsToTargetsMock })) +function createMockStore() { + const targets: SshTarget[] = [] + let deletedAliases: string[] = [] + const removedTombstones: RemovedSshTargetTombstone[] = [] + const reassignments: { oldTargetId: string; newTargetId: string }[] = [] + let generationCounter = 0 + + const dropTombstone = (oldTargetId: string) => { + const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...kept) + } + + return { + allocateSshTargetGeneration: vi.fn(() => { + generationCounter += 1 + return generationCounter + }), + getSshTargets: vi.fn(() => [...targets]), + getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)), + addSshTarget: vi.fn((target: SshTarget) => targets.push(target)), + updateSshTarget: vi.fn((id: string, updates: Partial>) => { + const target = targets.find((t) => t.id === id) + if (!target) { + return null + } + Object.assign(target, updates) + return { ...target } + }), + removeSshTarget: vi.fn((id: string) => { + const idx = targets.findIndex((t) => t.id === id) + if (idx !== -1) { + targets.splice(idx, 1) + } + }), + getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), + addDeletedSshConfigAlias: vi.fn((alias: string) => { + if (!deletedAliases.includes(alias)) { + deletedAliases.push(alias) + } + }), + removeDeletedSshConfigAlias: vi.fn((alias: string) => { + deletedAliases = deletedAliases.filter((entry) => entry !== alias) + }), + clearDeletedSshConfigAliases: vi.fn(() => { + deletedAliases = [] + }), + removedTombstones, + reassignments, + getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]), + addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => { + const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...filtered, tombstone) + }), + removeRemovedSshTargetTombstone: vi.fn(dropTombstone), + // Nothing in this mock stores automations, so releasing always drops. + releaseRemovedSshTargetTombstone: vi.fn(dropTombstone), + reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { + reassignments.push({ oldTargetId, newTargetId }) + // Pretend one repo referenced the old id. + return ['repo-1'] + }) + } +} + describe('SshConnectionStore', () => { let mockStore: ReturnType let sshStore: SshConnectionStore diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index a329c935fee..0d67587b920 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -3,7 +3,6 @@ import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' import { buildRemovedSshTargetTombstone, readoptOrphanedWorkspacesForTarget @@ -85,11 +84,6 @@ export class SshConnectionStore { return next } - /** Exclusive managed-orcad ownership of a target; see ssh-target-orcad-claims. */ - getOrcadRuntimeClaims(): SshTargetOrcadClaims { - return new SshTargetOrcadClaims(this.store) - } - updateTarget(id: string, updates: Partial>): SshTarget | null { const existing = this.store.getSshTarget(id) // Why: a new runtime choice or endpoint must re-run the ladder, not replay the old rung. @@ -167,7 +161,6 @@ export class SshConnectionStore { const alias = normalizeSshConfigAlias(existing.configHost ?? existing.label) if ( existing.source === 'manual' || - isRuntimeOwnedSshTarget(existing) || (existing.source === undefined && !isLegacyConfigImportTarget(existing)) ) { manualAliases.add(alias) @@ -260,7 +253,7 @@ export function getRuntimeOwnedSshTargetId(runtimeId: string): string { } export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { - return target.owner !== undefined || target.orcadProvisioning !== undefined + return target.owner?.type === 'on-demand-runtime' } function isLegacyConfigImportTarget(target: SshTarget): boolean { diff --git a/src/main/ssh/ssh-connection-system-work-drain.test.ts b/src/main/ssh/ssh-connection-system-work-drain.test.ts deleted file mode 100644 index e4785495b20..00000000000 --- a/src/main/ssh/ssh-connection-system-work-drain.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { - createCallbacks, - createResolvedConfig, - createTarget, - fenceSshConnectionWork -} from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' -import { - downloadFileViaSystemSsh, - uploadDirectoryViaSystemSsh, - uploadFileViaSystemSsh, - writeFileViaSystemSsh, - writeBufferViaSystemSsh -} from './ssh-system-fallback' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) - -it('holds an admitted system upload session across reset until close and uploads settle', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - const session = await conn.openFileUploadSession() - const fence = fenceSshConnectionWork(conn) - await expect(conn.openFileUploadSession()).rejects.toThrow('admission_closed') - await session.uploadFile('/first', '/remote-first') - const pending = Promise.withResolvers() - vi.mocked(uploadFileViaSystemSsh).mockReturnValueOnce(pending.promise) - const upload = session.uploadFile('/second', '/remote-second') - session.close() - await expect(session.uploadFile('/third', '/remote-third')).rejects.toThrow('session_closed') - expect(() => fence.assertDrained()).toThrow('not_drained') - const draining = fence.drain(new AbortController().signal) - expect(conn.getState().status).toBe('connected') - pending.resolve() - await upload - await draining - fence.assertDrained() - expect(uploadFileViaSystemSsh).toHaveBeenCalledTimes(2) -}) - -it.each(['download', 'upload', 'writeFile', 'writeBuffer'])( - 'drains the whole admitted system SSH %s without canceling it', - async (kind) => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - const pending = Promise.withResolvers() - const invoke = () => { - if (kind === 'download') { - return conn.downloadFile('/remote', '/local') - } - if (kind === 'upload') { - return conn.uploadDirectory('/local', '/remote') - } - if (kind === 'writeFile') { - return conn.writeFile('/remote', 'text') - } - return conn.writeBuffer('/remote', Buffer.from('bytes')) - } - const operation = - kind === 'download' - ? downloadFileViaSystemSsh - : kind === 'upload' - ? uploadDirectoryViaSystemSsh - : kind === 'writeFile' - ? writeFileViaSystemSsh - : writeBufferViaSystemSsh - vi.mocked(operation).mockReturnValueOnce(pending.promise) - const running = invoke() - const fence = fenceSshConnectionWork(conn) - const done = vi.fn() - const draining = fence.drain(new AbortController().signal).then(done) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - await expect(invoke()).rejects.toThrow('admission_closed') - expect(operation).toHaveBeenCalledTimes(1) - expect(conn.getState().status).toBe('connected') - pending.resolve() - await running - await draining - fence.assertDrained() - } -) diff --git a/src/main/ssh/ssh-connection-test-fixtures.ts b/src/main/ssh/ssh-connection-test-fixtures.ts index dce4a05cb98..cfed6a01061 100644 --- a/src/main/ssh/ssh-connection-test-fixtures.ts +++ b/src/main/ssh/ssh-connection-test-fixtures.ts @@ -1,8 +1,7 @@ import { EventEmitter } from 'node:events' import { vi } from 'vitest' import type { Mock } from 'vitest' -import type { SshConnection, SshConnectionCallbacks } from './ssh-connection' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' +import type { SshConnectionCallbacks } from './ssh-connection' import type { SshResolvedConfig } from './ssh-config-parser' import type { SshTarget } from '../../shared/ssh-types' @@ -125,9 +124,3 @@ export function createFailingSystemSshProcess(code: number): MockSystemSshProces }) return proc } - -// The public reset fence lands with T3; until then the connection's own ledger fence drains the same work. -export function fenceSshConnectionWork(conn: SshConnection) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnection owns exactly one private workLedger. - return (conn as unknown as { workLedger: SshConnectionWorkLedger }).workLedger.fenceForReset() -} diff --git a/src/main/ssh/ssh-connection-transport-closure.test.ts b/src/main/ssh/ssh-connection-transport-closure.test.ts deleted file mode 100644 index 858c264bf2f..00000000000 --- a/src/main/ssh/ssh-connection-transport-closure.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { createCallbacks, createResolvedConfig, createTarget } from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' -import type { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() -}) - -async function fixture() { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const closed = vi.fn() - const remove = conn.subscribeTransportClosure(closed) - await conn.connect() - const client = conn.getClient()! - vi.spyOn(client, 'end').mockImplementation(() => client) - return { conn, client, closed, remove } -} - -it('notifies only after disposal and physical client closure, including late subscribers', async () => { - const f = await fixture() - expect(f.closed).not.toHaveBeenCalled() - await f.conn.disconnect() - expect(f.closed).not.toHaveBeenCalled() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - const late = vi.fn() - f.conn.subscribeTransportClosure(late) - expect(late).toHaveBeenCalledOnce() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - expect(late).toHaveBeenCalledOnce() -}) - -it('remembers physical closure before disposal but does not report it early', async () => { - const f = await fixture() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() - await f.conn.disconnect() - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('holds notification through tracked work and both forwarded resources', async () => { - const f = await fixture() - const pending = Promise.withResolvers() - const operation = f.conn.prepareForwardRoute(() => pending.promise) - const socket = new EventEmitter() - const channel = new EventEmitter() - f.client.forwardOut = vi.fn((_a, _b, _c, _d, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback?.(undefined, channel as never) - return f.client - }) - f.conn.forwardOut(f.client, socket, '127.0.0.1', 0, 'host', 443, vi.fn()) - await f.conn.disconnect() - f.client.emit('close') - channel.emit('close') - expect(f.closed).not.toHaveBeenCalled() - socket.emit('close') - expect(f.closed).not.toHaveBeenCalled() - pending.resolve() - await operation - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('waits for every allocated proxy even after its active pointer is cleared', async () => { - const f = await fixture() - const proxy = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private transport close ledger. - const ledger = (f.conn as unknown as { transportCloseLedger: SshTransportCloseLedger }) - .transportCloseLedger - ledger.track(proxy) - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() - proxy.emit('close') - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('unsubscribes without changing ordinary disconnection', async () => { - const f = await fixture() - f.remove() - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() -}) - -it('isolates observer failures from disconnect and other observers', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - conn.subscribeTransportClosure(() => { - throw new Error('observer') - }) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - await expect(conn.disconnect()).resolves.toBeUndefined() - expect(observed).toHaveBeenCalledOnce() -}) - -it('never treats unproven system SSH cleanup as physical closure', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - await conn.connect() - await conn.disconnect() - expect(observed).not.toHaveBeenCalled() -}) - -it.each(['initial', 'reconnect', 'system'] as const)( - 'waits for active %s allocation work', - async (kind) => { - const f = kind === 'reconnect' ? await fixture() : null - const conn = - f?.conn ?? new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - const config = Promise.withResolvers() - vi.mocked(resolveWithSshG).mockReturnValueOnce(config.promise) - const opening = ( - kind === 'initial' - ? conn.connect() - : kind === 'reconnect' - ? conn.reconnect() - : conn.connectViaSystemSsh() - ).catch(() => {}) - await conn.disconnect() - f?.client.emit('close') - expect(observed).not.toHaveBeenCalled() - config.resolve(null) - await opening - expect(observed).toHaveBeenCalledTimes(kind === 'system' ? 0 : 1) - } -) - -it('retains failed ledger drainage rather than signaling successful closure', async () => { - const f = await fixture() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private work ledger. - const work = (f.conn as unknown as { workLedger: SshConnectionWorkLedger }).workLedger - const opening = work.beginChannelOpen() - work.fenceForReset() - opening.close(new Error('unproven channel')) - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() -}) - -it('refuses fresh forwarding work after notifying physical closure', async () => { - const f = await fixture() - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - const allocate = vi.fn(() => new EventEmitter()) - const prepare = vi.fn(async () => {}) - expect(() => f.conn.openForwardSocket(allocate)).toThrow( - expect.objectContaining({ name: 'AbortError' }) - ) - await expect(f.conn.prepareForwardRoute(prepare)).rejects.toMatchObject({ name: 'AbortError' }) - expect(allocate).not.toHaveBeenCalled() - expect(prepare).not.toHaveBeenCalled() -}) diff --git a/src/main/ssh/ssh-connection-transport-closure.ts b/src/main/ssh/ssh-connection-transport-closure.ts deleted file mode 100644 index 3a92bc29b42..00000000000 --- a/src/main/ssh/ssh-connection-transport-closure.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * One-shot subscribers told when every transport resource a connection allocated has physically - * closed and its tracked work has drained. That is local lifetime evidence only: it never proves a - * remote process exited. - */ -export class SshTransportClosureSubscribers { - private readonly subscribers = new Set<() => void>() - - constructor(private readonly isPhysicallyClosed: () => boolean) {} - - subscribe(onClosed: () => void): () => void { - this.subscribers.add(onClosed) - this.notify() - return () => { - this.subscribers.delete(onClosed) - } - } - - notify(): void { - if (!this.subscribers.size || !this.isPhysicallyClosed()) { - return - } - const subscribers = [...this.subscribers] - this.subscribers.clear() - for (const onClosed of subscribers) { - try { - onClosed() - } catch { - // Observers must not interrupt physical transport cleanup. - } - } - } -} diff --git a/src/main/ssh/ssh-connection-work-drain.test.ts b/src/main/ssh/ssh-connection-work-drain.test.ts deleted file mode 100644 index 24448fe7b06..00000000000 --- a/src/main/ssh/ssh-connection-work-drain.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { EventEmitter } from 'node:events' -import { beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { - createCallbacks, - createTarget, - fenceSshConnectionWork -} from './ssh-connection-test-fixtures' -import { - resetSshConnectionMocks, - pendingSftpCallback, - ssh2Mock -} from './ssh-connection-test-harness' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) - -it('does not automatically replace an explicitly single-lifetime SSH transport', async () => { - const options = { automaticReconnect: false } - const conn = new SshConnection(createTarget(), createCallbacks(), options) - options.automaticReconnect = true - await conn.connect() - const client = conn.getClient()! - vi.useFakeTimers() - try { - client.emit('close') - expect(conn.getClient()).toBeNull() - expect(conn.getState().status).toBe('disconnected') - expect(vi.getTimerCount()).toBe(0) - await vi.advanceTimersByTimeAsync(60_000) - expect(conn.getClient()).toBeNull() - expect(conn.getState().reconnectAttempt).toBe(0) - } finally { - await conn.disconnect() - vi.useRealTimers() - } -}) - -it('accounts for forward-route startup before reset and blocks new socket factories', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const release = Promise.withResolvers() - const preparing = conn.prepareForwardRoute(async () => { - await release.promise - return conn.openForwardSocket(() => new EventEmitter()) - }) - const fence = fenceSshConnectionWork(conn) - const forbidden = vi.fn(() => new EventEmitter()) - expect(() => conn.openForwardSocket(forbidden)).toThrow('admission_closed') - expect(forbidden).not.toHaveBeenCalled() - expect(() => fence.assertDrained()).toThrow('not_drained') - release.resolve() - const admitted = await preparing - expect(() => fence.assertDrained()).toThrow('not_drained') - admitted.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('routes actual connection forwarding through admission and rejects a replaced client', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - const socket = new EventEmitter() - client.forwardOut = vi.fn((_a, _b, _c, _d, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback?.(undefined, channel as never) - return client - }) - conn.forwardOut(client, socket, '127.0.0.1', 0, 'remote', 443, vi.fn()) - const fence = fenceSshConnectionWork(conn) - expect(() => - conn.forwardOut(client, new EventEmitter(), '127.0.0.1', 0, 'remote', 443, vi.fn()) - ).toThrow('admission_closed') - expect(() => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stand-in for a different ssh2 client; only its identity is compared. - conn.forwardOut({} as never, new EventEmitter(), '127.0.0.1', 0, 'remote', 443, vi.fn()) - ).toThrow('client_changed') - channel.emit('close') - expect(() => fence.assertDrained()).toThrow('not_drained') - socket.emit('close') - await fence.drain(new AbortController().signal) - expect(client.forwardOut).toHaveBeenCalledOnce() -}) - -it('tracks exact remote socket forwarding through a late open and physical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - let accept: Parameters[1] | undefined - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - accept = callback - return client - }) - const received = vi.fn() - conn.forwardStreamLocal(client, '/saved/incumbent.sock', received) - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledWith( - '/saved/incumbent.sock', - expect.any(Function) - ) - const fence = fenceSshConnectionWork(conn) - expect(() => fence.assertDrained()).toThrow('not_drained') - expect(() => conn.forwardStreamLocal(client, '/other.sock', vi.fn())).toThrow('admission_closed') - const channel = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - accept!(undefined, channel as never) - expect(received).toHaveBeenCalledWith(undefined, channel) - expect(() => fence.assertDrained()).toThrow('not_drained') - channel.emit('close') - await fence.drain(new AbortController().signal) - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledOnce() -}) - -it('rejects stale clients and non-Unix endpoints before remote socket forwarding', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - client.openssh_forwardOutStreamLocal = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stand-in for a different ssh2 client; only its identity is compared. - expect(() => conn.forwardStreamLocal({} as never, '/saved.sock', vi.fn())).toThrow( - 'client_changed' - ) - for (const endpoint of ['relative.sock', '\\\\.\\pipe\\saved', '/saved\0.sock']) { - expect(() => conn.forwardStreamLocal(client, endpoint, vi.fn())).toThrow('endpoint_invalid') - } - expect(client.openssh_forwardOutStreamLocal).not.toHaveBeenCalled() -}) - -it('does not treat an uncertain remote socket open as drained', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - client.openssh_forwardOutStreamLocal = vi.fn(() => { - throw new Error('uncertain-open') - }) - expect(() => conn.forwardStreamLocal(client, '/saved.sock', vi.fn())).toThrow('uncertain-open') - const fence = fenceSshConnectionWork(conn) - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('uncertain-open') -}) - -it('surfaces remote socket extension refusal without a fallback', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - let accept: Parameters[1] | undefined - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - accept = callback - return client - }) - const received = vi.fn() - conn.forwardStreamLocal(client, '/saved.sock', received) - const fence = fenceSshConnectionWork(conn) - const refused = new Error('extension unsupported') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh2 passes no channel on a refused open; the callback type omits that case. - accept!(refused, undefined as never) - expect(received).toHaveBeenCalledWith(refused, undefined) - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('extension unsupported') - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledOnce() -}) - -it('retains remote socket channel errors through physical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - conn.forwardStreamLocal(client, '/saved.sock', vi.fn()) - const fence = fenceSshConnectionWork(conn) - channel.emit('error', new Error('connection lost')) - channel.emit('close') - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('connection lost') -}) - -it('keeps the upload session SFTP channel tracked after logical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - const opening = conn.openFileUploadSession() - const fence = fenceSshConnectionWork(conn) - const channel = Object.assign(new EventEmitter(), { end: vi.fn() }) - pendingSftpCallback?.(undefined, channel) - const session = await opening - session.close() - expect(channel.end).toHaveBeenCalledOnce() - expect(() => fence.assertDrained()).toThrow('not_drained') - channel.emit('close') - await fence.drain(new AbortController().signal) - fence.assertDrained() -}) - -it('accounts for an SFTP open begun before fencing until its channel physically closes', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - const opening = conn.sftp() - const fence = fenceSshConnectionWork(conn) - const done = vi.fn() - const draining = fence.drain(new AbortController().signal).then(done) - const channel = Object.assign(new EventEmitter(), { end: vi.fn() }) - pendingSftpCallback?.(undefined, channel) - await opening - expect(done).not.toHaveBeenCalled() - expect(channel.end).not.toHaveBeenCalled() - await expect(conn.sftp()).rejects.toThrow('admission_closed') - channel.emit('close') - await draining - fence.assertDrained() -}) - -it('retains an opening timeout as unverifiable and never force-closes it to make drain pass', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - vi.useFakeTimers() - try { - const opening = conn.sftp().catch((error: unknown) => error) - const fence = fenceSshConnectionWork(conn) - const draining = expect(fence.drain(new AbortController().signal)).rejects.toThrow('timed out') - await vi.advanceTimersByTimeAsync(30_000) - await opening - await draining - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('timed out') - expect(conn.getState().status).toBe('connected') - } finally { - vi.useRealTimers() - } -}) diff --git a/src/main/ssh/ssh-connection-work-ledger.test.ts b/src/main/ssh/ssh-connection-work-ledger.test.ts deleted file mode 100644 index 25fa62e3653..00000000000 --- a/src/main/ssh/ssh-connection-work-ledger.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it } from 'vitest' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -const signal = () => new AbortController().signal - -it('allows admitted work to open nested channels after fencing and drains children independently', async () => { - const ledger = new SshConnectionWorkLedger() - const continueOperation = Promise.withResolvers() - let child!: ReturnType - const parent = ledger.run(async () => { - await continueOperation.promise - await ledger.run(async () => { - child = ledger.beginChannelOpen() - child.bind(new EventEmitter()) - }) - }) - const fence = ledger.fenceForReset() - let drained = false - const draining = fence.drain(signal()).then(() => { - drained = true - }) - continueOperation.resolve() - await parent - expect(drained).toBe(false) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - child.close() - await draining - fence.assertDrained() -}) - -it('refuses stale async scope work once its admitted parent has settled', async () => { - const ledger = new SshConnectionWorkLedger() - const releaseStale = Promise.withResolvers() - const staleResult = Promise.withResolvers() - await ledger.run(async () => { - void releaseStale.promise.then(() => { - try { - ledger.beginChannelOpen() - staleResult.resolve('incorrectly admitted') - } catch (error) { - staleResult.resolve(error) - } - }) - }) - const fence = ledger.fenceForReset() - releaseStale.resolve() - expect(await staleResult.promise).toMatchObject({ - message: 'ssh_connection_work_admission_closed' - }) - await fence.drain(signal()) -}) - -it('exempts only the exact captured control channel and keeps ordinary admission fenced', async () => { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const transfer = ledger.beginChannelOpen() - transfer.bind(new EventEmitter()) - expect(() => ledger.fenceForReset(new EventEmitter())).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - const fence = ledger.fenceForReset(resource) - expect(() => ledger.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - await expect(ledger.run(async () => undefined)).rejects.toThrow( - 'ssh_connection_work_admission_closed' - ) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - transfer.close() - await fence.drain(signal()) - fence.assertDrained() - control.close(new Error('expected reset transport close')) - fence.assertDrained() - expect(() => ledger.fenceForReset(resource)).toThrow('ssh_connection_work_already_fenced') -}) - -it('refuses ambiguous control-channel bindings without publishing a fence', async () => { - const ledger = new SshConnectionWorkLedger() - const resource = new EventEmitter() - const first = ledger.beginChannelOpen() - const second = ledger.beginChannelOpen() - first.bind(resource) - second.bind(resource) - expect(() => ledger.fenceForReset(resource)).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - await ledger.run(async () => undefined) - second.close() - await ledger.fenceForReset(resource).drain(signal()) -}) - -it('keeps uncertain pre-fence openings unconfirmed after late channel close', async () => { - const ledger = new SshConnectionWorkLedger() - const opening = ledger.beginChannelOpen() - const uncertainty = new Error('open timed out without closure evidence') - opening.markUnverifiable(uncertainty) - const fence = ledger.fenceForReset() - await expect(fence.drain(signal())).rejects.toBe(uncertainty) - opening.bind(new EventEmitter()) - opening.close() - expect(fence.assertDrained).toThrow(uncertainty) - await expect(fence.drain(signal())).rejects.toBe(uncertainty) -}) - -it('retains operation failures observed while draining', async () => { - const ledger = new SshConnectionWorkLedger() - const operation = Promise.withResolvers() - const failure = new Error('upload failed') - const running = ledger.run(() => operation.promise).catch((error: unknown) => error) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - operation.reject(failure) - expect(await running).toBe(failure) - expect(await draining).toBe(failure) - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('retains a channel failure even after repeated successful close calls', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('channel failed') - channel.close(failure) - channel.close() - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('wakes an in-flight drain when an opening becomes unverifiable without waiting for close', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('opening outcome became unverifiable') - let observed: unknown - const draining = fence.drain(signal()).catch((error: unknown) => { - observed = error - }) - channel.markUnverifiable(failure) - await new Promise((resolve) => setImmediate(resolve)) - const observedBeforeClose = observed - channel.close() - await draining - expect(observedBeforeClose).toBe(failure) -}) - -it('reports one failed channel without waiting for unrelated admitted channels to close', async () => { - const ledger = new SshConnectionWorkLedger() - const failedChannel = ledger.beginChannelOpen() - const liveChannel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('selected transfer failed') - let observed: unknown - const draining = fence.drain(signal()).catch((error: unknown) => { - observed = error - }) - failedChannel.close(failure) - await new Promise((resolve) => setImmediate(resolve)) - const observedBeforeOtherClose = observed - liveChannel.close() - await draining - expect(observedBeforeOtherClose).toBe(failure) -}) - -it('cancels only the wait and permits exact drain retry without reopening admission', async () => { - const ledger = new SshConnectionWorkLedger() - const opening = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const controller = new AbortController() - const cancellation = new Error('user cancelled waiting') - const draining = fence.drain(controller.signal).catch((error: unknown) => error) - controller.abort(cancellation) - expect(await draining).toBe(cancellation) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - expect(() => ledger.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - opening.bind(new EventEmitter()) - opening.close() - await fence.drain(signal()) - fence.assertDrained() -}) - -it('isolates admission, async ancestry, failures, and control identities between connections', async () => { - const first = new SshConnectionWorkLedger() - const second = new SshConnectionWorkLedger() - const resource = new EventEmitter() - first.beginChannelOpen().bind(resource) - const firstFence = first.fenceForReset(resource) - await second.run(async () => { - expect(() => first.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - }) - expect(() => second.fenceForReset(resource)).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - const channel = second.beginChannelOpen() - const secondFence = second.fenceForReset() - channel.close(new Error('second connection failure')) - await expect(secondFence.drain(signal())).rejects.toThrow('second connection failure') - await firstFence.drain(signal()) -}) - -it('rejects rebinding and binding an already settled opening', () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - channel.bind(new EventEmitter()) - expect(() => channel.bind(new EventEmitter())).toThrow( - 'ssh_connection_work_channel_binding_changed' - ) - const closed = ledger.beginChannelOpen() - closed.close() - expect(() => closed.bind(new EventEmitter())).toThrow( - 'ssh_connection_work_channel_binding_changed' - ) -}) - -it.each([false, true])( - 'preserves drained proof after exact exempt control closes (error=%s)', - async (withError) => { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const fence = ledger.fenceForReset(resource) - await fence.drain(signal()) - if (withError) { - control.markUnverifiable(new Error('control stream closed after response')) - } - control.close(withError ? new Error('control closed') : undefined) - fence.assertDrained() - await fence.drain(signal()) - expect(() => ledger.beginChannelOpen()).toThrow('admission_closed') - } -) - -it('control closure cannot erase retained unrelated work or transport failure', async () => { - for (const transportFailure of [false, true]) { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const work = ledger.beginChannelOpen() - const fence = ledger.fenceForReset(resource) - if (transportFailure) { - work.close() - await fence.drain(signal()) - ledger.markTransportUnverifiable() - } else { - work.close(new Error('unrelated write failed')) - } - control.close() - expect(fence.assertDrained).toThrow( - transportFailure ? 'transport_unverifiable' : 'unrelated write failed' - ) - await expect(fence.drain(signal())).rejects.toThrow() - } -}) diff --git a/src/main/ssh/ssh-connection-work-ledger.ts b/src/main/ssh/ssh-connection-work-ledger.ts deleted file mode 100644 index 183bc270a39..00000000000 --- a/src/main/ssh/ssh-connection-work-ledger.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Identity of a channel or socket; only compared by reference. */ -export type SshConnectionWorkChannel = Pick - -type WorkEntry = { - done: Promise - finish: () => void - resource?: SshConnectionWorkChannel - failure?: Error -} - -export class SshConnectionWorkAdmissionClosedError extends Error { - constructor() { - super('ssh_connection_work_admission_closed') - this.name = 'SshConnectionWorkAdmissionClosedError' - } -} - -/** Tracks local operation/channel lifetime; channel closure is not remote process-exit proof. */ -export class SshConnectionWorkLedger { - private readonly pending = new Set() - private readonly scope = new AsyncLocalStorage() - private fenced = false - private exempt: WorkEntry | undefined - private failure: Error | undefined - private readonly failureWake = Promise.withResolvers() - - constructor( - private readonly onChange: () => void = () => {}, - private readonly assertAdmission: () => void = () => {} - ) {} - - isDrained(): boolean { - return this.pending.size === 0 && !this.failure - } - - private recordFailure(error: Error): void { - this.failure ??= error - this.failureWake.resolve(this.failure) - } - - markTransportUnverifiable(): void { - if (this.fenced) { - this.recordFailure(new Error('ssh_connection_reset_transport_unverifiable')) - } - } - - private admit(): WorkEntry { - this.assertAdmission() - const parent = this.scope.getStore() - if (this.fenced && (!parent || !this.pending.has(parent) || parent === this.exempt)) { - throw new SshConnectionWorkAdmissionClosedError() - } - const completion = Promise.withResolvers() - const entry: WorkEntry = { done: completion.promise, finish: completion.resolve } - this.pending.add(entry) - return entry - } - - private settle(entry: WorkEntry, error?: unknown): void { - if (!this.pending.delete(entry)) { - return - } - if (error !== undefined) { - entry.failure = error instanceof Error ? error : new Error(String(error)) - } - if (this.fenced && entry !== this.exempt && entry.failure) { - this.recordFailure(entry.failure) - } - entry.finish() - this.onChange() - } - - async run(operation: () => Promise): Promise { - const entry = this.admit() - return this.scope.run(entry, async () => { - try { - const result = await operation() - this.settle(entry) - return result - } catch (error) { - this.settle(entry, error) - throw error - } - }) - } - - beginSession() { - const entry = this.admit() - let closed = false - let closeFailed = false - let active = 0 - const finish = () => { - if (closed && !closeFailed && active === 0) { - this.settle(entry) - } - } - return { - run: async (operation: () => Promise): Promise => { - if (closed) { - throw new Error('ssh_connection_work_session_closed') - } - active++ - try { - return await this.scope.run(entry, () => this.run(operation)) - } catch (error) { - entry.failure ??= error instanceof Error ? error : new Error(String(error)) - if (this.fenced) { - this.recordFailure(entry.failure) - } - throw error - } finally { - active-- - finish() - } - }, - close: (error?: unknown) => { - closed = true - if (error !== undefined) { - // Failed closure must remain observable even if reset has not started yet. - closeFailed = true - entry.failure ??= error instanceof Error ? error : new Error(String(error)) - if (this.fenced) { - this.recordFailure(entry.failure) - } - } - finish() - } - } - } - - beginChannelOpen() { - const entry = this.admit() - let bound = false - return { - bind: (resource: SshConnectionWorkChannel) => { - if (bound || !this.pending.has(entry)) { - throw new Error('ssh_connection_work_channel_binding_changed') - } - entry.resource = resource - bound = true - }, - markUnverifiable: (error: Error) => { - if (this.pending.has(entry)) { - entry.failure ??= error - if (this.fenced && entry !== this.exempt) { - this.recordFailure(error) - } - } - }, - close: (error?: unknown) => this.settle(entry, error) - } - } - - fenceForReset(controlChannel?: SshConnectionWorkChannel) { - if (this.fenced) { - throw new Error('ssh_connection_work_already_fenced') - } - const matches = controlChannel - ? [...this.pending].filter((entry) => entry.resource === controlChannel) - : [] - if (controlChannel && matches.length !== 1) { - throw new Error('ssh_connection_work_control_channel_unproven') - } - this.exempt = matches[0] - this.fenced = true - const assertDrained = () => { - if (this.failure) { - throw this.failure - } - if ([...this.pending].some((entry) => entry !== this.exempt)) { - throw new Error('ssh_connection_work_not_drained') - } - } - return { - drain: async (signal: AbortSignal) => { - signal.throwIfAborted() - for (;;) { - if (this.failure) { - throw this.failure - } - const pending = [...this.pending].filter((entry) => entry !== this.exempt) - for (const entry of pending) { - if (entry.failure) { - throw entry.failure - } - } - if (pending.length === 0) { - assertDrained() - return - } - await waitForPromiseWithSignal( - Promise.race([ - Promise.all(pending.map((entry) => entry.done)), - this.failureWake.promise.then((error) => { - throw error - }) - ]), - signal - ) - } - }, - assertDrained - } - } -} diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index d7150af631a..f75c4b4f172 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -1,34 +1,6 @@ /* eslint-disable max-lines -- Why: SSH connection lifecycle, credential retries, reconnect policy, and transport fallback are intentionally co-located so state transitions stay auditable in one file. */ import * as net from 'node:net' import { Client as SshClient } from 'ssh2' -import { sshProxyRouteDigest, type SshConnectionDestination } from './ssh-connection-destination' -import { - SshConnectionWorkLedger, - type SshConnectionWorkChannel -} from './ssh-connection-work-ledger' -import { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import { SshTransportClosureSubscribers } from './ssh-connection-transport-closure' -import { disconnectAndAwaitSshTransportClose } from './ssh-connection-close-drain' -import { openTrackedSshUploadSession } from './ssh-upload-session-lifetime' -import { - createSshFileUploadSession, - downloadSshFile, - uploadSshDirectory, - writeSshBuffer, - writeSshFile, - type SshFileTransferHost, - type SshRemoteFileOptions -} from './ssh-connection-file-transfers' -import { - assertSshForwardClient, - assertSshStreamLocalForwardAllowed, - forwardTrackedSshChannel, - forwardTrackedSshStreamLocalChannel -} from './ssh-forward-channel-lifetime' -import { - openTrackedSshSocket, - trackSshConnectionChannelLifetime -} from './ssh-connection-channel-lifetime' import type { ChildProcess } from 'node:child_process' import type { ClientChannel, @@ -44,6 +16,11 @@ import { getOrcaControlSocketPath, spawnSystemSsh, spawnSystemSshCommand, + downloadFileViaSystemSsh, + uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, + writeFileViaSystemSsh, type SystemSshBuildArgsOptions, type SystemSshProcess } from './ssh-system-fallback' @@ -106,10 +83,18 @@ import { requiresSystemSshForSecurityKey, shouldUseSystemSshTransport } from './ssh-transport-selection' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' import type { FileUploadSession } from '../providers/types' -import { openSshSessionChannelWithRetry, waitForSshChannelOpen } from './ssh-channel-open' +import { isSshSessionLimitError } from './ssh-session-limit-error' import { withTimeout } from '../../shared/promise-timeout-fallback' -import { isEphemeralRuntimeSshOwner } from '../../shared/managed-orcad-ssh-owner' +import { + createLinkedSshFileTransferSignal, + raceSftpFileTransferWithAbort +} from './ssh-file-transfer-abort' export type { SshConnectionCallbacks } from './ssh-connection-utils' type HostKeyTrustSources = { @@ -119,6 +104,12 @@ type HostKeyTrustSources = { trustedHostKeys: Awaited> } +type SshRemoteFileOptions = { + hostPlatform?: RemoteHostPlatform + // Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch. + sftpNamespace?: SftpNamespacePathMapping +} + /** Bounds the trust-source reads that run before the handshake, which nothing else times out. */ const HOST_KEY_SOURCE_READ_TIMEOUT_MS = 5_000 // Counts every INFO_REQUEST of the handshake, so it must cover each partial-success stage the auth @@ -127,6 +118,13 @@ const SSH_KEYBOARD_INTERACTIVE_MAX_ROUNDS = 8 const SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS = SSH_CREDENTIAL_TIMEOUT_MS + 5_000 const SSH_KEYBOARD_INTERACTIVE_MAX_PROMPTS = 8 +// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. +const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000 + +// Why: MaxSessions servers can transiently refuse a channel open; a refused open never ran the command, so retry is safe. +const SESSION_LIMIT_OPEN_RETRIES = 4 +const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150 + function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfig | null { if (!config) { return null @@ -202,37 +200,6 @@ export class SshConnection { private useSystemSshTransport = false private credentialAbortController = new AbortController() private readonly pendingSsh2Clients = new Set() - // Local lifetime evidence only: a closed channel or client never proves a remote process exited. - private readonly workLedger = new SshConnectionWorkLedger( - () => this.transportClosure.notify(), - () => { - if (this.disposed) { - throw createSshOperationAbortError() - } - } - ) - private readonly transportCloseLedger = new SshTransportCloseLedger(() => - this.transportClosure.notify() - ) - private readonly transportClosure = new SshTransportClosureSubscribers( - () => - this.disposed && - this.state.status === 'disconnected' && - this.activeConnectCalls === 0 && - this.pendingSsh2Clients.size === 0 && - !this.unprovenStartupTransport && - this.transportCloseLedger.isClosed() && - this.workLedger.isDrained() - ) - private readonly automaticReconnect: boolean - // Set once drained for close; no reconnect or new transport may replace the fenced one. - private resetConnectionFenced = false - private activeConnectCalls = 0 - // Sticky: a system-ssh child's transport closure cannot be proven from here. - private unprovenStartupTransport = false - private executionDestination: - | { client: SshClient; generation: number; destination: SshConnectionDestination } - | undefined private state: SshConnectionState private callbacks: SshConnectionCallbacks private target: SshTarget @@ -247,12 +214,7 @@ export class SshConnection { private hostKeyFingerprint: string | undefined private connectGeneration = 0 - constructor( - target: SshTarget, - callbacks: SshConnectionCallbacks, - options: { automaticReconnect?: boolean } = {} - ) { - this.automaticReconnect = options.automaticReconnect !== false + constructor(target: SshTarget, callbacks: SshConnectionCallbacks) { this.target = target this.callbacks = callbacks this.state = { @@ -270,60 +232,9 @@ export class SshConnection { getConnectGeneration(): number { return this.connectGeneration } - /** Equals the connect generation; later slices name it by the transport it identifies. */ - getTransportGeneration(): number { - return this.connectGeneration - } - /** The ssh2 destination proven by the current handshake; undefined on system SSH or when stale. */ - getExecutionDestination(): SshConnectionDestination | undefined { - const captured = this.executionDestination - return !this.disposed && - !this.useSystemSshTransport && - this.state.status === 'connected' && - captured?.client === this.client && - captured?.generation === this.connectGeneration - ? captured.destination - : undefined - } - subscribeTransportClosure(onClosed: () => void): () => void { - return this.transportClosure.subscribe(onClosed) - } getClient(): SshClient | null { return this.client } - prepareForwardRoute(prepare: () => Promise): Promise { - return this.workLedger.run(prepare) - } - openForwardSocket(open: () => T): T { - return openTrackedSshSocket(this.workLedger, open, this.reportUnhandledChannelError('socket')) - } - forwardOut( - client: SshClient, - localSocket: SshConnectionWorkChannel, - ...args: Parameters - ): void { - assertSshForwardClient(this.client, client) - const report = this.reportUnhandledChannelError('forward') - forwardTrackedSshChannel(this.workLedger, client, localSocket, report, ...args) - } - forwardStreamLocal( - client: SshClient, - ...args: Parameters - ): void { - assertSshForwardClient(this.client, client) - const usable = - !this.disposed && !this.useSystemSshTransport && this.state.status === 'connected' - assertSshStreamLocalForwardAllowed(usable, args[0]) - const report = this.reportUnhandledChannelError('stream-local forward') - forwardTrackedSshStreamLocalChannel(this.workLedger, client, report, ...args) - } - /** Names the connection and channel kind when a tracked channel's error has no other handler. */ - private reportUnhandledChannelError(kind: string): (error: Error) => void { - return (error) => - console.warn( - `[ssh] Unhandled ${kind} channel error for ${this.target.label}: ${error.message}` - ) - } usesSystemSshTransport(): boolean { return this.useSystemSshTransport } @@ -358,11 +269,7 @@ export class SshConnection { return this.cachedPassphrase != null || this.cachedPassword != null } - exec(cmd: string, options?: SshExecOptions): Promise { - return this.workLedger.run(() => this.execUntracked(cmd, options)) - } - - private async execUntracked(cmd: string, options?: SshExecOptions): Promise { + async exec(cmd: string, options?: SshExecOptions): Promise { if (options?.signal?.aborted) { throw createSshOperationAbortError() } @@ -377,31 +284,21 @@ export class SshConnection { } const client = this.client const remoteCommand = options?.wrapCommand === false ? cmd : wrapRemoteCommandForPosixShell(cmd) - return openSshSessionChannelWithRetry( + return this.openSessionChannelWithRetry( () => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + this.waitForSshCallback( 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), (channel) => channel.close(), options?.signal, - true, - this.reportUnhandledChannelError('exec') + true ), options?.signal ) } /** Interactive login shell over a session channel with pty-req; ssh2 transport only. */ - shell(pty: PseudoTtyOptions, options: ShellOptions = {}): Promise { - // Plain SSH mode's terminals must drain with the rest of this connection's work. - return this.workLedger.run(() => this.shellUntracked(pty, options)) - } - - private async shellUntracked( - pty: PseudoTtyOptions, - options: ShellOptions - ): Promise { + async shell(pty: PseudoTtyOptions, options: ShellOptions = {}): Promise { if (this.useSystemSshTransport) { throw new Error('Interactive SSH shells are not available when using system SSH transport') } @@ -409,26 +306,16 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return openSshSessionChannelWithRetry(() => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + return this.openSessionChannelWithRetry(() => + this.waitForSshCallback( 'SSH shell channel timed out', (callback) => client.shell(pty, options, callback), - (channel) => channel.close(), - undefined, - false, - this.reportUnhandledChannelError('shell') + (channel) => channel.close() ) ) } - sftp(options?: AbortSignal | { signal?: AbortSignal }): Promise { - return this.workLedger.run(() => this.sftpUntracked(options)) - } - - private async sftpUntracked( - options?: AbortSignal | { signal?: AbortSignal } - ): Promise { + async sftp(options?: AbortSignal | { signal?: AbortSignal }): Promise { // Why: relay transfers pass a signal directly, while filesystem factories use an options object. const signal = options && 'aborted' in options ? options : options?.signal if (signal?.aborted) { @@ -441,95 +328,373 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return openSshSessionChannelWithRetry( + return this.openSessionChannelWithRetry( () => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + this.waitForSshCallback( 'SSH SFTP channel timed out', (callback) => client.sftp(callback), (sftp) => sftp.end(), - signal, - false, - this.reportUnhandledChannelError('sftp') + signal ), signal ) } - uploadDirectory( + private async openSessionChannelWithRetry( + open: () => Promise, + signal?: AbortSignal + ): Promise { + let lastError: unknown + for (let attempt = 0; attempt < SESSION_LIMIT_OPEN_RETRIES; attempt++) { + if (attempt > 0) { + // Why: an abort must release the backoff immediately, not after it. + if (!signal?.aborted) { + await new Promise((resolve) => { + const onDelayDone = (): void => { + clearTimeout(delayTimer) + signal?.removeEventListener('abort', onDelayDone) + resolve() + } + const delayTimer = setTimeout(onDelayDone, SESSION_LIMIT_OPEN_RETRY_DELAY_MS) + signal?.addEventListener('abort', onDelayDone, { once: true }) + }) + } + if (signal?.aborted) { + throw createSshOperationAbortError() + } + } + try { + return await open() + } catch (err) { + if (!isSshSessionLimitError(err)) { + throw err + } + lastError = err + } + } + throw lastError + } + + private waitForSshCallback( + timeoutMessage: string, + register: (callback: (error: Error | undefined, value: T) => void) => void, + cleanupLateValue?: (value: T) => void, + signal?: AbortSignal, + trackRemoteCommandTermination = false + ): Promise { + return new Promise((resolve, reject) => { + type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } + let settled = false + let unconfirmedOpenError: ChannelOpenTerminationError | null = null + const markOpenUnconfirmed = (error: Error): Error => { + if (!trackRemoteCommandTermination) { + return error + } + unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) + return unconfirmedOpenError + } + // Why: an in-flight open holds a MaxSessions slot; reject the caller now, then settle from the open callback once the late channel closes. + let abortRequested = false + let abortDeadlineTimer: NodeJS.Timeout | undefined + const cleanup = (): void => { + clearTimeout(timer) + clearTimeout(abortDeadlineTimer) + signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + abortRequested = true + // Why: a hung socket may never invoke the open callback; bound the aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS. + abortDeadlineTimer = setTimeout(() => { + settled = true + cleanup() + reject(markOpenUnconfirmed(createSshOperationAbortError())) + }, ABORTED_CHANNEL_CLOSE_GRACE_MS) + } + const timer = setTimeout(() => { + settled = true + cleanup() + reject( + markOpenUnconfirmed( + abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) + ) + ) + }, CONNECT_TIMEOUT_MS) + const discardLateValue = (value: T, onClose?: () => void): void => { + const emitter = value as Partial & { + resume?: () => void + stderr?: Partial & { resume?: () => void } + } + const swallowLateError = (): void => {} + emitter.on?.('error', swallowLateError) + emitter.stderr?.on?.('error', swallowLateError) + if (onClose) { + emitter.once?.('close', onClose) + } + // Why: ssh2 withholds CHANNEL_CLOSE while discarded exec streams remain unread, and teardown errors have no other owner. + emitter.resume?.() + emitter.stderr?.resume?.() + try { + cleanupLateValue?.(value) + } catch { + /* best effort */ + } + } + const rejectAfterClose = (value: T): void => { + const abortError = markOpenUnconfirmed(createSshOperationAbortError()) + const emitter = value as Partial & { + resume?: () => void + stderr?: { resume?: () => void } + } + let finished = false + const done = (): void => { + if (finished) { + return + } + finished = true + clearTimeout(closeGraceTimer) + emitter.removeListener?.('close', confirmAndDone) + reject(abortError) + } + const confirmAndDone = (): void => { + if (unconfirmedOpenError === abortError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + done() + } + // Why: bounded so a remote that never confirms the close can't hang the aborted operation forever. + const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) + if (typeof emitter.once === 'function') { + emitter.once('close', confirmAndDone) + } + // Why: ssh2 withholds 'close' until the channel's streams are drained; nobody else will read this discarded channel. + discardLateValue(value) + if (typeof emitter.once !== 'function') { + done() + } + } + const finish = (error: Error | undefined, value?: T): void => { + if (settled) { + // Why: ssh2 can invoke the open callback after our timeout rejected; close that late channel so it isn't left open with no owner. + if (!error && value !== undefined) { + discardLateValue(value, () => { + if (unconfirmedOpenError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + }) + } + return + } + settled = true + cleanup() + if (abortRequested) { + if (!error && value !== undefined) { + rejectAfterClose(value) + } else { + reject(createSshOperationAbortError()) + } + return + } + if (error) { + reject(error) + return + } + resolve(value as T) + } + if (signal?.aborted) { + // No open is in flight yet, so failing fast leaks nothing. + cleanup() + reject(createSshOperationAbortError()) + return + } + signal?.addEventListener('abort', onAbort, { once: true }) + + try { + // Why: higher-level channel timers start only after ssh2's open callback; a stale SSH socket can otherwise keep exec/sftp stuck. + register(finish) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + async uploadDirectory( localDir: string, remoteDir: string, options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - return this.workLedger.run(() => - uploadSshDirectory(this.fileTransferHost(), localDir, remoteDir, options) + // Why: relay-deploy timeout and connection teardown are independent owners; either must stop a transfer that could outlive its lock. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + // Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace. + const transfer = (async (): Promise => { + const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) + linkedSignal.signal.throwIfAborted() + const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + await uploadDirectory(sftp, localDir, targetDir, localDir, { + signal: linkedSignal.signal + }) + })() + await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + return () => sftp.removeListener('close', onClose) + }) + } finally { + endSftp() + } + return + } + await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() + } } - downloadFile( + async downloadFile( remotePath: string, localPath: string, options?: SshRemoteFileOptions ): Promise { - return this.workLedger.run(() => - downloadSshFile(this.fileTransferHost(), remotePath, localPath, options) - ) + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp') + await fastGetViaSftp(sftp, remotePath, localPath) + } finally { + sftp.end() + } + return + } + await downloadFileViaSystemSsh(this.target, remotePath, localPath, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) } - openFileUploadSession(options?: SshRemoteFileOptions): Promise { - return openTrackedSshUploadSession(this.workLedger, () => - createSshFileUploadSession(this.fileTransferHost(), options) - ) + async openFileUploadSession(options?: SshRemoteFileOptions): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + const { uploadFile } = await import('./sftp-upload') + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFile(sftp, localPath, remotePath, uploadOptions), + close: () => sftp.end() + } + } + // Why: disconnect replaces the connection controller, so an existing import session must stay bound to the signal and SSH config it opened with. + const signal = this.systemOperationAbortController.signal + const buildArgsOptions = this.getSystemSshBuildArgsOptions() + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFileViaSystemSsh(this.target, localPath, remotePath, { + signal, + hostPlatform: options?.hostPlatform, + exclusive: uploadOptions?.exclusive, + ...buildArgsOptions + }), + close: () => {} + } } - writeFile( + async writeFile( remotePath: string, contents: string, options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - return this.workLedger.run(() => - writeSshFile(this.fileTransferHost(), remotePath, contents, options) + // Keep package/version writes under the same dual cancellation contract as uploads. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + // Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace. + const write = (async (): Promise => { + const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) + linkedSignal.signal.throwIfAborted() + const { writeStringViaSftp } = await import('./sftp-upload') + await writeStringViaSftp(sftp, targetPath, contents) + })() + await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + return () => sftp.removeListener('close', onClose) + }) + } finally { + endSftp() + } + return + } + await writeFileViaSystemSsh(this.target, remotePath, contents, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() + } } - writeBuffer( + async writeBuffer( remotePath: string, contents: Buffer, options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean } ): Promise { - return this.workLedger.run(() => - writeSshBuffer(this.fileTransferHost(), remotePath, contents, options) - ) - } - - private fileTransferHost(): SshFileTransferHost { - return { - target: this.target, - usesSystemSshTransport: () => this.useSystemSshTransport, - systemOperationSignal: () => this.systemOperationAbortController.signal, - systemSshBuildArgsOptions: () => this.getSystemSshBuildArgsOptions(), - sftp: (signal) => this.sftp(signal) + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { uploadBuffer } = await import('./sftp-upload') + await uploadBuffer(sftp, contents, remotePath, options) + } finally { + sftp.end() + } + return } + await writeBufferViaSystemSsh(this.target, remotePath, contents, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + append: options?.append, + exclusive: options?.exclusive, + ...this.getSystemSshBuildArgsOptions() + }) } async connect(): Promise { - this.activeConnectCalls++ - try { - await this.connectInitialAttempts() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private assertConnectionReplacementAllowed(): void { - if (this.resetConnectionFenced) { - throw new Error('ssh_connection_reset_replacement_refused') - } - } - - private async connectInitialAttempts(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed) { throw new Error('Connection disposed') } @@ -537,7 +702,6 @@ export class SshConnection { let lastError: Error | null = null for (let attempt = 0; attempt < INITIAL_RETRY_ATTEMPTS; attempt++) { - this.assertConnectionReplacementAllowed() const connectGeneration = ++this.connectGeneration try { await this.attemptConnect(connectGeneration) @@ -655,7 +819,6 @@ export class SshConnection { } private async attemptConnect(connectGeneration = ++this.connectGeneration): Promise { - this.executionDestination = undefined this.credentialAbortController.abort() this.credentialAbortController = new AbortController() this.setState('connecting') @@ -704,10 +867,8 @@ export class SshConnection { // Why: ssh2 doesn't support ProxyCommand/ProxyJump natively; spawn the resolved proxy and pipe its stdin/stdout as config.sock. const effectiveProxy = resolveEffectiveProxy(this.target, resolved) - const proxyRouteDigest = sshProxyRouteDigest(effectiveProxy) if (effectiveProxy) { const proxy = spawnProxyCommand(effectiveProxy, config.host!, config.port!, config.username!) - this.transportCloseLedger.track(proxy.process) this.proxyProcess = proxy.process config.sock = proxy.sock } @@ -720,7 +881,7 @@ export class SshConnection { } try { - await this.doSsh2Connect(config, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(config, connectGeneration) } catch (err) { if (!(err instanceof Error)) { this.proxyProcess?.kill() @@ -796,7 +957,7 @@ export class SshConnection { if (keyConfig.privateKey || keyConfig.password) { this.respawnProxy(keyConfig, effectiveProxy) try { - await this.doSsh2Connect(keyConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(keyConfig, connectGeneration) return } catch (keyErr) { // Same reason as above: the retry re-runs the handshake, so it can be the attempt that @@ -831,7 +992,7 @@ export class SshConnection { this.cachedPassphrase = val keyConfig.passphrase = val this.respawnProxy(keyConfig, effectiveProxy) - await this.doSsh2Connect(keyConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(keyConfig, connectGeneration) return } } @@ -881,7 +1042,7 @@ export class SshConnection { this.cachedPassphrase = val credentialRetryConfig.passphrase = val this.respawnProxy(credentialRetryConfig, effectiveProxy) - await this.doSsh2Connect(credentialRetryConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(credentialRetryConfig, connectGeneration) return } } @@ -896,7 +1057,7 @@ export class SshConnection { this.cachedPassword = val credentialRetryConfig.password = val this.respawnProxy(credentialRetryConfig, effectiveProxy) - await this.doSsh2Connect(credentialRetryConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(credentialRetryConfig, connectGeneration) return } } @@ -907,7 +1068,6 @@ export class SshConnection { } async reconnect(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed || this.state.status === 'connecting') { return } @@ -924,7 +1084,6 @@ export class SshConnection { } private async doSystemSshProbe(connectGeneration: number): Promise { - this.unprovenStartupTransport = true this.useSystemSshTransport = true this.client = null this.proxyProcess?.kill() @@ -1085,7 +1244,6 @@ export class SshConnection { if (!this.isCurrentConnectAttempt(connectGeneration)) { throw this.createCancelledConnectAttemptError() } - this.unprovenStartupTransport = true const proc = spawnSystemSsh(this.target, this.getSystemSshBuildArgsOptions()) this.systemSsh = proc let settled = false @@ -1176,18 +1334,10 @@ export class SshConnection { options === undefined && Object.keys(buildArgsOptions).length === 0 ? undefined : { ...options, ...buildArgsOptions } - const work = this.workLedger.beginChannelOpen() - let channel: ClientChannel - try { - channel = - commandOptions === undefined - ? spawnSystemSshCommand(this.target, command) - : spawnSystemSshCommand(this.target, command, commandOptions) - trackSshConnectionChannelLifetime(work, channel, this.reportUnhandledChannelError('command')) - } catch (error) { - work.close(error) - throw error - } + const channel = + commandOptions === undefined + ? spawnSystemSshCommand(this.target, command) + : spawnSystemSshCommand(this.target, command, commandOptions) this.systemCommandChannels.add(channel) const onAbort = (): void => { channel.close() @@ -1231,7 +1381,6 @@ export class SshConnection { } this.proxyProcess?.kill() const p = spawnProxyCommand(proxy, config.host!, config.port!, config.username!) - this.transportCloseLedger.track(p.process) this.proxyProcess = p.process config.sock = p.sock } @@ -1284,16 +1433,7 @@ export class SshConnection { return sources } - private async doSsh2Connect( - config: ConnectConfig, - connectGeneration: number, - proxyRouteDigest: string - ): Promise { - const endpoint = { - host: config.host ?? '', - port: config.port ?? 22, - username: config.username ?? '' - } + private async doSsh2Connect(config: ConnectConfig, connectGeneration: number): Promise { const hostKeyResolved = this.hostKeyResolvedConfig const { host: hostKeyLookupHost, isHostKeyAlias } = resolveKnownHostsLookupHost( hostKeyResolved, @@ -1336,7 +1476,6 @@ export class SshConnection { : undefined return new Promise((resolve, reject) => { const client = new SshClient() - this.transportCloseLedger.track(client) this.pendingSsh2Clients.add(client) let settled = false let startupTimer: ReturnType | null = null @@ -1359,7 +1498,6 @@ export class SshConnection { // the live attempt's error, and substituting a new Error drops ssh2's `code`, so a transient // ECONNRESET would stop being classified as retryable. let hostKeyRejection: HostKeyVerificationError | null = null - let acceptedFingerprint: string | undefined // Why the fingerprint is still recorded: the relay uses the negotiated server key to isolate // shared-home install locks without comparing PIDs from an unrelated SSH host. Its format is @@ -1373,7 +1511,7 @@ export class SshConnection { hostKeyStoreFile: boundSshHostKeyStoreFile() ?? undefined, strictHostKeyChecking: hostKeyResolved?.strictHostKeyChecking ?? 'ask', isHostKeyAlias, - isEphemeralRuntimeTarget: isEphemeralRuntimeSshOwner(this.target.owner), + isEphemeralRuntimeTarget: this.target.owner?.type === 'on-demand-runtime', siteConfigSuppressed, // A file that EXISTS and will not open is the absence of evidence, not evidence of a new // host — the entry that would have said "this key changed" may be in it. An ABSENT file is @@ -1411,7 +1549,6 @@ export class SshConnection { connectGeneration === this.connectGeneration ) { this.hostKeyFingerprint = decision.fingerprint - acceptedFingerprint = decision.fingerprint } if (decision.action === 'reject') { hostKeyRejection = new HostKeyVerificationError( @@ -1510,20 +1647,6 @@ export class SshConnection { } settled = true this.client = client - // Only this successful handshake can publish recovery destination authority. - this.executionDestination = acceptedFingerprint - ? { - client, - generation: connectGeneration, - destination: Object.freeze({ - version: 1, - transport: 'ssh2', - ...endpoint, - hostKeyFingerprint: acceptedFingerprint, - proxyRouteDigest - }) - } - : undefined this.proxyProcess = null this.setupDisconnectHandler(client) cleanupStartupListeners() @@ -1581,7 +1704,6 @@ export class SshConnection { if (this.disposed || this.client !== client) { return } - this.workLedger.markTransportUnverifiable() this.client = null this.scheduleReconnect() } @@ -1592,18 +1714,13 @@ export class SshConnection { return } console.warn(`[ssh] Connection error for ${this.target.label}: ${err.message}`) - this.workLedger.markTransportUnverifiable() this.client = null this.scheduleReconnect() }) } private scheduleReconnect(): void { - if (this.disposed || this.resetConnectionFenced || this.reconnectTimer) { - return - } - if (!this.automaticReconnect) { - this.setState('disconnected') + if (this.disposed || this.reconnectTimer) { return } const decision = this.reconnectLadder.next(Date.now()) @@ -1618,7 +1735,7 @@ export class SshConnection { ) this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null - if (this.disposed || this.resetConnectionFenced) { + if (this.disposed) { return } await this.runReconnectAttempt() @@ -1626,19 +1743,6 @@ export class SshConnection { } private async runReconnectAttempt(): Promise { - this.activeConnectCalls++ - try { - await this.runTrackedReconnectAttempt() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private async runTrackedReconnectAttempt(): Promise { - if (this.resetConnectionFenced) { - return - } const connectGeneration = ++this.connectGeneration try { // Why: reset before connecting so the 'connected' broadcast carries reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. @@ -1714,21 +1818,9 @@ export class SshConnection { } async connectViaSystemSsh(): Promise { - this.activeConnectCalls++ - try { - return await this.connectTrackedViaSystemSsh() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private async connectTrackedViaSystemSsh(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed) { throw new Error('Connection disposed') } - this.unprovenStartupTransport = true const connectGeneration = ++this.connectGeneration this.systemSsh?.kill() this.systemSsh = null @@ -1762,7 +1854,6 @@ export class SshConnection { // Why: register the reconnect handler only after handshake succeeds (the onExit above guards with `settled`). proc.onExit(() => { if (!this.disposed && this.systemSsh === proc) { - this.workLedger.markTransportUnverifiable() this.systemSsh = null this.scheduleReconnect() } @@ -1781,60 +1872,7 @@ export class SshConnection { } } - /** Owned migration transport only; local closure never proves remote process exit. */ - async disconnectAndDrain(signal: AbortSignal): Promise { - signal.throwIfAborted() - // An unconnected non-reconnecting owner has nothing live left to fence. - const settledExclusiveStartup = - !this.automaticReconnect && - this.activeConnectCalls === 0 && - this.pendingSsh2Clients.size === 0 && - !this.unprovenStartupTransport - if ( - this.useSystemSshTransport || - this.unprovenStartupTransport || - this.activeConnectCalls > 0 || - (!this.client && !settledExclusiveStartup) - ) { - await this.disconnect() - throw new Error('ssh_connection_close_transport_unproven') - } - let fence: ReturnType - try { - if ( - this.client && - (this.disposed || this.state.status !== 'connected' || this.pendingSsh2Clients.size > 0) - ) { - throw new Error('ssh_connection_reset_transport_not_connected') - } - fence = this.workLedger.fenceForReset() - this.resetConnectionFenced = true - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - } catch (error) { - try { - await this.disconnect() - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'ssh_connection_close_cleanup_failed') - } - throw error - } - await disconnectAndAwaitSshTransportClose({ - liveResources: [ - ...(this.client ? [this.client] : []), - ...(this.proxyProcess ? [this.proxyProcess] : []) - ], - disconnect: () => this.disconnect(), - transportCloseLedger: this.transportCloseLedger, - fence, - signal - }) - } - async disconnect(): Promise { - this.executionDestination = undefined this.disposed = true this.connectGeneration += 1 if (this.reconnectTimer) { @@ -1863,7 +1901,6 @@ export class SshConnection { this.useSystemSshTransport = false this.reconnectLadder.reset() this.setState('disconnected') - this.transportClosure.notify() } private setState(status: SshConnectionStatus, error?: string): void { diff --git a/src/main/ssh/ssh-forward-channel-lifetime.test.ts b/src/main/ssh/ssh-forward-channel-lifetime.test.ts deleted file mode 100644 index ae9bc18ef76..00000000000 --- a/src/main/ssh/ssh-forward-channel-lifetime.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { Client, ClientChannel } from 'ssh2' -import { expect, it, vi } from 'vitest' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { forwardTrackedSshChannel } from './ssh-forward-channel-lifetime' - -function fixture() { - const ledger = new SshConnectionWorkLedger() - const local = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ledger only needs the channel's EventEmitter surface. - const remote = new EventEmitter() as ClientChannel - let opened!: NonNullable[4]> - const client = { - forwardOut: vi.fn((_a, _b, _c, _d, callback) => { - opened = callback - }) - } - const callback = vi.fn() - const start = () => - forwardTrackedSshChannel( - ledger, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the forwardOut the helper calls. - client as unknown as Client, - local, - undefined, - '127.0.0.1', - 0, - 'remote.internal', - 443, - callback - ) - return { ledger, local, remote, client, callback, start, opened: () => opened } -} - -it('tracks a pending open and both physical ends without terminating either', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - expect(fence.assertDrained).toThrow('not_drained') - f.opened()(undefined, f.remote) - expect(f.callback).toHaveBeenCalledWith(undefined, f.remote) - f.remote.emit('close') - expect(fence.assertDrained).toThrow('not_drained') - f.local.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('retains late channels after the local socket closes', async () => { - const f = fixture() - f.start() - f.local.emit('close') - const fence = f.ledger.fenceForReset() - f.opened()(undefined, f.remote) - expect(fence.assertDrained).toThrow('not_drained') - f.remote.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('refuses fenced opens before forwarding or subscribing to the new socket', () => { - const f = fixture() - f.ledger.fenceForReset() - expect(f.start).toThrow('admission_closed') - expect(f.client.forwardOut).not.toHaveBeenCalled() - expect(f.local.listenerCount('close')).toBe(0) -}) - -it('retains open failures during reset rather than claiming successful drain', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - const failure = new Error('forward failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh2 passes no channel on a refused open; the callback type omits that case. - f.opened()(failure, undefined as never) - f.local.emit('close') - await expect(fence.drain(new AbortController().signal)).rejects.toBe(failure) -}) - -it('retains synchronous open uncertainty even when reset begins later', async () => { - const f = fixture() - const failure = new Error('transport failed') - f.client.forwardOut.mockImplementation(() => { - throw failure - }) - expect(f.start).toThrow(failure) - f.local.emit('close') - await expect(f.ledger.fenceForReset().drain(new AbortController().signal)).rejects.toBe(failure) -}) - -it('aborting observation preserves the admitted sockets and allows another drain', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - const observer = new AbortController() - const draining = fence.drain(observer.signal).catch((error: unknown) => error) - const failure = new Error('observer stopped') - observer.abort(failure) - expect(await draining).toBe(failure) - f.opened()(undefined, f.remote) - f.local.emit('close') - f.remote.emit('close') - await fence.drain(new AbortController().signal) -}) diff --git a/src/main/ssh/ssh-forward-channel-lifetime.ts b/src/main/ssh/ssh-forward-channel-lifetime.ts deleted file mode 100644 index 902b40b66ee..00000000000 --- a/src/main/ssh/ssh-forward-channel-lifetime.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Client } from 'ssh2' -import { - trackSshConnectionChannelLifetime, - type SshChannelErrorReporter -} from './ssh-connection-channel-lifetime' -import type { - SshConnectionWorkChannel, - SshConnectionWorkLedger -} from './ssh-connection-work-ledger' - -/** A forward must ride the connection's current client, never one a reconnect replaced. */ -export function assertSshForwardClient(current: Client | null, requested: Client): void { - if (current !== requested) { - throw new Error('ssh_connection_forward_client_changed') - } -} - -/** Remote stream-local forwarding needs a live ssh2 transport and an absolute Unix socket path. */ -export function assertSshStreamLocalForwardAllowed( - transportUsable: boolean, - socketPath: string -): void { - if (!transportUsable) { - throw new Error('ssh_connection_streamlocal_transport_unavailable') - } - if (!socketPath.startsWith('/') || socketPath.includes('\0')) { - throw new Error('ssh_connection_streamlocal_endpoint_invalid') - } -} - -/** The channel remains tracked until physical close, including late open replies. */ -export function forwardTrackedSshStreamLocalChannel( - ledger: SshConnectionWorkLedger, - client: Client, - onUnhandledError: SshChannelErrorReporter | undefined, - ...args: Parameters -): void { - const opening = ledger.beginChannelOpen() - const [socketPath, callback] = args - try { - client.openssh_forwardOutStreamLocal(socketPath, (error, channel) => { - if (error) { - opening.close(error) - } else { - trackSshConnectionChannelLifetime(opening, channel, onUnhandledError) - } - callback(error, channel) - }) - } catch (error) { - opening.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} - -export function forwardTrackedSshChannel( - ledger: SshConnectionWorkLedger, - client: Client, - localSocket: SshConnectionWorkChannel, - onUnhandledError: SshChannelErrorReporter | undefined, - ...args: Parameters -): void { - const opening = ledger.beginChannelOpen() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), localSocket, onUnhandledError) - const [sourceHost, sourcePort, host, port, callback] = args - try { - client.forwardOut(sourceHost, sourcePort, host, port, (error, channel) => { - if (error) { - opening.close(error) - } else { - trackSshConnectionChannelLifetime(opening, channel, onUnhandledError) - } - callback?.(error, channel) - }) - } catch (error) { - opening.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} diff --git a/src/main/ssh/ssh-forward-socket-admission.test.ts b/src/main/ssh/ssh-forward-socket-admission.test.ts deleted file mode 100644 index e337eab6865..00000000000 --- a/src/main/ssh/ssh-forward-socket-admission.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { openTrackedSshSocket } from './ssh-connection-channel-lifetime' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -it('acquires admission before a socket factory can connect', () => { - const ledger = new SshConnectionWorkLedger() - const open = vi.fn(() => new EventEmitter()) - ledger.fenceForReset() - expect(() => openTrackedSshSocket(ledger, open)).toThrow('admission_closed') - expect(open).not.toHaveBeenCalled() -}) - -it('waits for actual close rather than the destroyed flag', async () => { - const ledger = new SshConnectionWorkLedger() - const socket = openTrackedSshSocket(ledger, () => - Object.assign(new EventEmitter(), { destroyed: false }) - ) - const fence = ledger.fenceForReset() - socket.destroyed = true - expect(fence.assertDrained).toThrow('not_drained') - socket.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('retains socket-construction uncertainty without accepting a later reset', async () => { - const ledger = new SshConnectionWorkLedger() - const failure = new Error('socket setup failed') - expect(() => - openTrackedSshSocket(ledger, () => { - throw failure - }) - ).toThrow(failure) - await expect(ledger.fenceForReset().drain(new AbortController().signal)).rejects.toBe(failure) -}) diff --git a/src/main/ssh/ssh-multi-factor-authentication.test.ts b/src/main/ssh/ssh-multi-factor-authentication.test.ts index 4b22c9e6157..2ee643dea7a 100644 --- a/src/main/ssh/ssh-multi-factor-authentication.test.ts +++ b/src/main/ssh/ssh-multi-factor-authentication.test.ts @@ -1,6 +1,5 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import type * as Os from 'node:os' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { Client, @@ -11,16 +10,11 @@ import { type KeyboardAuthContext, type PasswordAuthContext } from 'ssh2' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { buildConnectConfig } from './ssh-connection-utils' -vi.mock('node:os', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, homedir: vi.fn(() => actual.tmpdir()) } -}) - // OpenSSH's default; a host that burns it disconnects before the MFA stage is reached. const MAX_AUTH_TRIES = 6 const PASSWORD = 'stage-one-password' @@ -192,11 +186,19 @@ function connectWithOrcaConfig( describe('multi-stage SSH authentication', () => { let tempDir: string let keyPaths: string[] + let homeEnv: { HOME?: string; USERPROFILE?: string } beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-mfa-')) - // Default-key discovery must never read the developer's SSH credentials. - vi.mocked(homedir).mockReturnValue(tempDir) + // Why: the cases below pass `resolved: null`, so `resolvePrivateKeys` falls through to + // `findDefaultKeyFile`, which reads `~/.ssh/id_*` through `homedir()`. On a developer + // machine that picks up a real key, and an encrypted one makes ssh2 reject with + // "Cannot parse privateKey" before authentication is exercised at all. Hosted CI has no + // key, so this only ever failed locally. Pointing home at the fixture directory keeps + // default-key discovery inside the test's control on every machine. + homeEnv = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } + process.env.HOME = tempDir + process.env.USERPROFILE = tempDir keyPaths = ['id_a', 'id_b'].map((name) => { const path = join(tempDir, name) writeFileSync(path, utils.generateKeyPairSync('ecdsa', { bits: 256 }).private) @@ -205,7 +207,14 @@ describe('multi-stage SSH authentication', () => { }) afterEach(() => { - vi.mocked(homedir).mockReturnValue(tmpdir()) + for (const key of ['HOME', 'USERPROFILE'] as const) { + const previous = homeEnv[key] + if (previous === undefined) { + delete process.env[key] + } else { + process.env[key] = previous + } + } rmSync(tempDir, { recursive: true, force: true }) }) diff --git a/src/main/ssh/ssh-provider-continuations.ts b/src/main/ssh/ssh-provider-continuations.ts deleted file mode 100644 index 418c1d89676..00000000000 --- a/src/main/ssh/ssh-provider-continuations.ts +++ /dev/null @@ -1,24 +0,0 @@ -const pendingByTarget = new Map>() - -/** Tracks local continuation settlement, never host execution or cancellation acknowledgment. */ -export async function runSshProviderContinuation( - targetId: string, - operation: () => Promise -): Promise { - const pending = pendingByTarget.get(targetId) ?? new Set() - const token = {} - pendingByTarget.set(targetId, pending) - pending.add(token) - try { - return await operation() - } finally { - pending.delete(token) - if (pending.size === 0 && pendingByTarget.get(targetId) === pending) { - pendingByTarget.delete(targetId) - } - } -} - -export function hasSshProviderContinuations(targetId: string): boolean { - return (pendingByTarget.get(targetId)?.size ?? 0) > 0 -} diff --git a/src/main/ssh/ssh-pty-consumer-resume-session.test.ts b/src/main/ssh/ssh-pty-consumer-resume-session.test.ts deleted file mode 100644 index 60ccf6cad12..00000000000 --- a/src/main/ssh/ssh-pty-consumer-resume-session.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { openSshPtyConsumerSession, resumeSshPtyConsumerSession } from './ssh-pty-consumer-session' - -function fixture() { - const controller = new AbortController() - const grant = { - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - serverBuildId: 'build', - role: 'session-owner', - clientGeneration: 3, - ownerGeneration: 3, - ownerLease: 'owner', - resumed: true, - capabilities: { outputFlowControl: { version: 1, windowSu: 256 } } - } - const request = vi.fn().mockResolvedValue(grant) - const isDisposed = vi.fn(() => false) - const mux: Pick = { request, isDisposed } - const options = { - clientInstanceId: 'client', - expectedServerBuildId: 'build', - resume: { ownerGeneration: 2, ownerLease: 'owner' }, - outputFlowControl: { requestedWindowSu: 512 }, - signal: controller.signal, - assertAuthority: vi.fn() - } - const run = () => resumeSshPtyConsumerSession(mux, options) - return { grant, request, isDisposed, mux, options, run, controller } -} - -it('resumes through only the dedicated method and passes cancellation to transport', async () => { - const f = fixture() - await expect(f.run()).resolves.toEqual({ - resumed: true, - state: { - mode: 'negotiated', - clientInstanceId: 'client', - clientGeneration: 3, - ownerGeneration: 3, - ownerLease: 'owner', - outputFlowControl: { version: 1, windowSu: 256 } - } - }) - expect(f.request).toHaveBeenCalledExactlyOnceWith( - 'pty.resumeClient', - { - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - clientInstanceId: 'client', - requestedRole: 'session-owner', - resume: { ownerGeneration: 2, ownerLease: 'owner' }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 512 } } - }, - { timeoutMs: 10_000, signal: f.controller.signal } - ) - expect(f.options.assertAuthority).toHaveBeenCalledTimes(2) -}) - -it.each([-32601, -32000])( - 'never falls back on host error %s, even with legacy runtime option', - async (code) => { - const f = fixture() - const error = Object.assign(new Error('host-refusal'), { code }) - f.request.mockRejectedValue(error) - const options = { ...f.options, allowSameBuildLegacyFallback: true } - const refusal = resumeSshPtyConsumerSession(f.mux, options) - // An old relay's method-not-found becomes a typed refusal; other host errors pass through. - const expected = - code === -32601 ? { message: 'pty_consumer_resume_unsupported', cause: error } : error - await expect(refusal).rejects.toMatchObject(expected) - expect(f.request).toHaveBeenCalledOnce() - expect(f.request.mock.calls[0][0]).toBe('pty.resumeClient') - } -) - -it.each([ - { ownerGeneration: 0 }, - { ownerGeneration: -1 }, - { ownerGeneration: 1.5 }, - { ownerGeneration: Number.MAX_SAFE_INTEGER + 1 }, - { ownerLease: '' }, - { ownerLease: 'x'.repeat(513) } -])('refuses invalid resume proof before RPC %j', async (proof) => { - const f = fixture() - Object.assign(f.options.resume, proof) - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() -}) - -it('refuses absent resume proof before RPC', async () => { - const f = fixture() - Reflect.deleteProperty(f.options, 'resume') - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() -}) - -it.each(['clientInstanceId', 'expectedServerBuildId'] as const)( - 'refuses missing %s before RPC', - async (field) => { - const f = fixture() - f.options[field] = '' - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() - } -) - -it.each([ - { ownerLease: 'different' }, - { ownerGeneration: 2 }, - { ownerGeneration: 1 }, - { resumed: false }, - { resumed: undefined }, - { ownerGeneration: 0 }, - { clientGeneration: 0 }, - { serverBuildId: 'other' }, - { role: 'observer' }, - { capabilities: undefined } -])('refuses invalid or non-resumed grant %j', async (change) => { - const f = fixture() - Object.assign(f.grant, change) - await expect(f.run()).rejects.toThrow() - expect(f.request).toHaveBeenCalledOnce() -}) - -it.each(['before', 'after'] as const)('refuses lost native authority %s RPC', async (when) => { - const f = fixture() - const revoke = () => - f.options.assertAuthority.mockImplementation(() => { - throw new Error('native-revoked') - }) - if (when === 'before') { - revoke() - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - revoke() - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('native-revoked') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it.each(['before', 'after'] as const)('refuses abort %s RPC', async (when) => { - const f = fixture() - if (when === 'before') { - f.controller.abort(new Error('aborted')) - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - f.controller.abort(new Error('aborted')) - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('aborted') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it.each(['before', 'after'] as const)('refuses disposed mux %s RPC', async (when) => { - const f = fixture() - if (when === 'before') { - f.isDisposed.mockReturnValue(true) - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - f.isDisposed.mockReturnValue(true) - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('transport_closed') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it('pins the original proof across await rather than accepting caller mutation', async () => { - const f = fixture() - f.request.mockImplementation(async () => { - await Promise.resolve() - f.options.resume.ownerLease = 'replacement' - f.options.resume.ownerGeneration = 100 - return f.grant - }) - await expect(f.run()).resolves.toMatchObject({ - resumed: true, - state: { ownerLease: 'owner', ownerGeneration: 3 } - }) - expect(f.request.mock.calls[0][1].resume).toEqual({ ownerLease: 'owner', ownerGeneration: 2 }) -}) - -it('leaves an old relay channel usable for openClient after it refuses pty.resumeClient', async () => { - const f = fixture() - f.request.mockRejectedValueOnce(Object.assign(new Error('Method not found'), { code: -32601 })) - await expect(f.run()).rejects.toThrow('pty_consumer_resume_unsupported') - expect(f.isDisposed()).toBe(false) - const { capabilities: _flow, ...plainGrant } = f.grant - f.request.mockResolvedValueOnce({ - ...plainGrant, - clientGeneration: 1, - ownerGeneration: 1, - resumed: false - }) - - const admission = await openSshPtyConsumerSession(f.mux, { - clientInstanceId: 'client', - expectedServerBuildId: 'build' - }) - - expect(admission).toMatchObject({ resumed: false, state: { mode: 'negotiated' } }) - expect(f.request.mock.calls.map(([method]) => method)).toEqual([ - 'pty.resumeClient', - 'pty.openClient' - ]) -}) diff --git a/src/main/ssh/ssh-pty-consumer-session.ts b/src/main/ssh/ssh-pty-consumer-session.ts index 3747a788762..aa58534909b 100644 --- a/src/main/ssh/ssh-pty-consumer-session.ts +++ b/src/main/ssh/ssh-pty-consumer-session.ts @@ -1,6 +1,5 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - PTY_CONSUMER_RESUME_CLIENT_METHOD, type PtyConsumerSessionGrant } from '../../shared/pty-consumer-session' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' @@ -99,90 +98,13 @@ function validateGrant( } export async function openSshPtyConsumerSession( - mux: Pick, + mux: SshChannelMultiplexer, options: OpenSshPtyConsumerSessionOptions -): Promise { - return requestPtyConsumerSession(mux, options, SSH_PTY_OPEN_CLIENT_METHOD) -} - -/** Dedicated RPC: old hosts refuse without minting a replacement claim. */ -export async function resumeSshPtyConsumerSession( - mux: Pick, - options: Omit & { - resume: NonNullable - signal: AbortSignal - assertAuthority: () => void - } -): Promise { - const resume = { ...options.resume } - const signal = options.signal - const assertAuthority = options.assertAuthority - const isDisposed = mux.isDisposed.bind(mux) - const assertCurrent = () => { - signal.throwIfAborted() - assertAuthority() - if (isDisposed()) { - throw new Error('pty_consumer_resume_transport_closed') - } - } - assertCurrent() - if ( - !Number.isSafeInteger(resume.ownerGeneration) || - resume.ownerGeneration <= 0 || - typeof resume.ownerLease !== 'string' || - !resume.ownerLease || - resume.ownerLease.length > 512 || - !options.clientInstanceId || - !options.expectedServerBuildId - ) { - throw new Error('pty_consumer_resume_required') - } - let admission: SshPtyConsumerAdmission - try { - admission = await requestPtyConsumerSession( - mux, - { - clientInstanceId: options.clientInstanceId, - expectedServerBuildId: options.expectedServerBuildId, - resume, - ...(options.outputFlowControl - ? { outputFlowControl: { ...options.outputFlowControl } } - : {}), - allowSameBuildLegacyFallback: false - }, - PTY_CONSUMER_RESUME_CLIENT_METHOD, - signal - ) - } catch (error) { - // Why: a relay that predates pty.resumeClient answers method-not-found; that is a refusal of - // resume on a still-open channel, not a transport failure, so callers can fall back to openClient. - if (typeof error === 'object' && error !== null && 'code' in error && error.code === -32601) { - throw new Error('pty_consumer_resume_unsupported', { cause: error }) - } - throw error - } - assertCurrent() - if ( - !admission.resumed || - admission.state.mode !== 'negotiated' || - admission.state.ownerLease !== resume.ownerLease || - admission.state.ownerGeneration <= resume.ownerGeneration - ) { - throw new Error('pty_consumer_resume_grant_mismatch') - } - return admission -} - -async function requestPtyConsumerSession( - mux: Pick, - options: OpenSshPtyConsumerSessionOptions, - method: string, - signal?: AbortSignal ): Promise { let result: unknown try { result = await mux.request( - method, + SSH_PTY_OPEN_CLIENT_METHOD, { protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, clientInstanceId: options.clientInstanceId, @@ -199,7 +121,7 @@ async function requestPtyConsumerSession( } : {}) }, - { timeoutMs: SSH_PTY_OPEN_CLIENT_TIMEOUT_MS, ...(signal ? { signal } : {}) } + { timeoutMs: SSH_PTY_OPEN_CLIENT_TIMEOUT_MS } ) } catch (error) { const code = (error as { code?: unknown })?.code diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index 10b2b4c3763..e7d5fea353c 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -47,7 +47,7 @@ export function isUnconfirmedSshCommandTermination( } export async function execCommand( - conn: Pick, + conn: SshConnection, command: string, options?: ExecCommandOptions ): Promise { diff --git a/src/main/ssh/ssh-relay-exec-input.test.ts b/src/main/ssh/ssh-relay-exec-input.test.ts deleted file mode 100644 index c5b6ffdeeb1..00000000000 --- a/src/main/ssh/ssh-relay-exec-input.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough, Readable } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { execCommand } from './ssh-relay-exec-command' - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('node:child_process', () => ({ spawn: spawnMock })) -vi.mock('./system-ssh-binary', () => ({ findSystemSsh: () => '/usr/bin/ssh' })) -import { spawnSystemSshCommand } from './system-ssh-command' - -function fakeSshProcess() { - const proc = Object.assign(new EventEmitter(), { - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn() - }) - spawnMock.mockReturnValue(proc) - let input = '' - proc.stdin.on('data', (chunk) => { - input += chunk.toString() - }) - proc.stdin.on('finish', () => { - proc.stdout.write('answer') - proc.emit('close', 0) - }) - return { proc, input: () => input } -} - -const target = { id: 't', label: 't', host: 'example.test', port: 22, username: 'u' } - -describe('system SSH command input', () => { - it('forwards end-of-input written to the facade to the ssh child', async () => { - const { proc, input } = fakeSshProcess() - const channel = spawnSystemSshCommand(target, 'reader') - const finished = new Promise((resolve) => proc.stdin.once('finish', resolve)) - - channel.end('secret') - await finished - - expect(input()).toBe('secret') - expect(proc.stdin.writableFinished).toBe(true) - }) - - it('streams command input through the real facade and returns the response', async () => { - const { proc, input } = fakeSshProcess() - const channel = spawnSystemSshCommand(target, 'reader') - - await expect( - execCommand({ exec: async () => channel, usesSystemSshTransport: () => true }, 'reader', { - stdin: Readable.from(['secret']) - }) - ).resolves.toBe('answer') - expect(input()).toBe('secret') - expect(proc.stdin.writableFinished).toBe(true) - }) -}) diff --git a/src/main/ssh/ssh-relay-install-lock.test.ts b/src/main/ssh/ssh-relay-install-lock.test.ts index d17d5725f43..d70409def41 100644 --- a/src/main/ssh/ssh-relay-install-lock.test.ts +++ b/src/main/ssh/ssh-relay-install-lock.test.ts @@ -14,7 +14,7 @@ vi.mock('./ssh-relay-gc-claim', () => ({ })) import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock, RemoteInstallLockBusyError } from './ssh-relay-install-lock' +import { acquireInstallLock } from './ssh-relay-install-lock' import { getRemoteHostPlatform } from './ssh-remote-platform' describe('acquireInstallLock', () => { @@ -46,29 +46,4 @@ describe('acquireInstallLock', () => { ) ).rejects.toBe(termination) }) - - it.each([ - [true, 'OK'], - [false, 'BUSY'] - ])('steals a stale lock only when takeover is allowed (%s)', async (allow, expected) => { - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('.steal')) { - return 'OK' - } - return command.includes('mkdir -p') ? '' : 'BUSY' - }) - const acquired = acquireInstallLock( - {} as SshConnection, - '/home/u/.orca-remote/.orcad-activation-transaction', - getRemoteHostPlatform('linux-x64'), - { relayGcClaim: false, allowStaleTakeover: allow, waitTimeoutMs: 0 } - ).then( - () => 'OK', - (error: unknown) => (error instanceof RemoteInstallLockBusyError ? 'BUSY' : String(error)) - ) - expect(await acquired).toBe(expected) - expect( - vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('.steal')) - ).toBe(allow) - }) }) diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts index ef570ecb2fe..eaa759bcf9a 100644 --- a/src/main/ssh/ssh-relay-install-lock.ts +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -32,16 +32,6 @@ export const INSTALL_LOCK_STALE_MS = 20 * 60_000 export const INSTALL_LOCK_STALE_SECONDS = INSTALL_LOCK_STALE_MS / 1000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') -export class RemoteInstallLockBusyError extends Error { - constructor(lockDir: string, timeoutMs: number) { - super( - `Could not acquire relay install lock at ${lockDir} after ${timeoutMs / 1000}s; ` + - 'another install is still in progress.' - ) - this.name = 'RemoteInstallLockBusyError' - } -} - function execHostCommand( conn: SshConnection, host: RemoteHostPlatform, @@ -87,16 +77,9 @@ export async function acquireInstallLock( lockName?: string /** False for a lock whose directory is not a relay version dir, so no GC claim can name it. */ relayGcClaim?: boolean - /** False when a held lock is a fence whose age cannot prove its owner's work is finished. */ - allowStaleTakeover?: boolean - waitTimeoutMs?: number } ): Promise { const lockDir = joinRemotePath(host, remoteRelayDir, options?.lockName ?? RELAY_INSTALL_LOCK_NAME) - const waitTimeoutMs = options?.waitTimeoutMs ?? INSTALL_LOCK_TIMEOUT_MS - if (!Number.isSafeInteger(waitTimeoutMs) || waitTimeoutMs < 0) { - throw new Error('Install lock wait timeout must be a non-negative integer.') - } const relayGcClaim = options?.relayGcClaim ?? true const isClaimed = (): Promise => relayGcClaim @@ -147,10 +130,7 @@ export async function acquireInstallLock( // A failed mkdir is lock contention; keep the connection-specific error // out of the user path until the bounded wait expires. } - if ( - options?.allowStaleTakeover !== false && - Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS - ) { + if (Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS) { lastStaleCheckAt = Date.now() // Why: recover an already-stale lock immediately, then keep checking in // case a fresh holder crosses the stale threshold while we are waiting. @@ -190,8 +170,12 @@ export async function acquireInstallLock( lastWaitLogAt = Date.now() console.info(`[ssh-relay] Waiting for install lock at ${lockDir}`) } - if (Date.now() - start >= waitTimeoutMs) { - throw new RemoteInstallLockBusyError(lockDir, waitTimeoutMs) + if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { + throw new Error( + `Could not acquire relay install lock at ${lockDir} after ${ + INSTALL_LOCK_TIMEOUT_MS / 1000 + }s; another install is still in progress.` + ) } await waitForInstallLockPoll(options?.signal) } diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index ac3ff4f0308..6e45077698a 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync, writeFileSync, mkdirSync, chmodSync, rmSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -59,9 +59,7 @@ function writeFakeRelay(dir: string): void { // below — so adding one cannot silently fail the completeness probe here. for (const filename of relayArtifactFilenames(false)) { if (filename !== 'relay.js') { - const artifactPath = join(dir, filename) - mkdirSync(dirname(artifactPath), { recursive: true }) - writeFileSync(artifactPath, '') + writeFileSync(join(dir, filename), '') } } writeFileSync( diff --git a/src/main/ssh/ssh-target-orcad-claims.test.ts b/src/main/ssh/ssh-target-orcad-claims.test.ts deleted file mode 100644 index c2849eb1685..00000000000 --- a/src/main/ssh/ssh-target-orcad-claims.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId -} from '../../shared/managed-orcad-ssh-owner' -import type { SshRemotePtyLease, SshTarget } from '../../shared/ssh-types' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -type SetupOptions = { - target?: Partial - repos?: { id: string; path: string; displayName: string }[] - folders?: { id: string; name: string; folderPath: string }[] - leases?: Partial[] -} - -function setup(options: SetupOptions = {}) { - let target: SshTarget = { - id: 'ssh-1', - label: 'Builder', - host: 'builder', - port: 22, - username: 'dev', - ...options.target - } - const flush = vi.fn(async () => {}) - const rows = { connectionId: 'ssh-1' } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only id, name, folderPath and connectionId. - const folders = (options.folders ?? []).map((f) => ({ ...f, ...rows })) as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only id, path, displayName, kind and connectionId. - const repos = (options.repos ?? []).map((r) => ({ ...r, ...rows })) as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only ptyId and state. - const leases = (options.leases ?? []) as never - const claims = new SshTargetOrcadClaims({ - ...emptyDependentStateStore(), - allocateSshTargetGeneration: () => 4, - flushPendingOrThrowAsync: flush, - getFolderWorkspaces: () => folders, - getRepos: () => repos, - getSshRemotePtyLeases: () => leases, - getSshTarget: (id) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - return { claims, flush, current: () => target } -} - -describe('managed orcad SSH target claims', () => { - it('claims an empty target with a generation and a resumable provisioning intent', () => { - const { claims, current } = setup() - const claimed = claims.claim('ssh-1', 'environment-1', 'Managed') - expect(getManagedOrcadOwnerEnvironmentId(claimed.owner)).toBe('environment-1') - expect(claimed.generation).toBe(4) - expect(current().orcadProvisioning).toEqual({ requestId: 'environment-1', name: 'Managed' }) - }) - - it('is idempotent for its own environment and keeps an earlier provisioning request', () => { - const { claims } = setup({ - target: { - generation: 2, - owner: createManagedOrcadSshOwner('environment-1'), - orcadProvisioning: { requestId: 'request-1', name: 'From dialog' } - } - }) - const claimed = claims.claim('ssh-1', 'environment-1', 'Other name') - expect(claimed.generation).toBe(2) - expect(claimed.orcadProvisioning).toEqual({ requestId: 'request-1', name: 'From dialog' }) - }) - - it('claims for SSH access without recording a provisioning intent', () => { - const { claims } = setup({ target: { generation: 1 } }) - expect(claims.claim('ssh-1', 'environment-1').orcadProvisioning).toBeUndefined() - }) - - it.each<[string, SetupOptions, string]>([ - ['another owner', { target: { owner: createManagedOrcadSshOwner('other') } }, 'already owned'], - [ - 'direct SSH repositories', - { repos: [{ id: 'repo-1', path: '/srv/app', displayName: 'app' }] }, - 'repositories or folder workspaces' - ], - [ - 'folder workspaces', - { folders: [{ id: 'folder-1', name: 'scratch', folderPath: '/srv/scratch' }] }, - 'repositories or folder workspaces' - ], - [ - 'a saved terminal lease', - { leases: [{ ptyId: 'pty-1', state: 'detached' }] }, - 'terminal-lease ×1 (pty-1 (detached))' - ], - [ - 'saved port forwards', - { - target: { - portForwards: [{ localPort: 1, remoteHost: 'localhost', remotePort: 2 }] - } - }, - 'port forwards' - ] - ])('refuses a target with %s and leaves it unclaimed', (_label, options, message) => { - const { claims, current } = setup(options) - expect(claims.preflight('ssh-1').claimable).toBe(false) - expect(() => claims.claim('ssh-1', 'environment-1', 'Managed')).toThrow(message) - expect(current().orcadProvisioning).toBeUndefined() - }) - - it('counts terminated and expired leases too, since they still name this host', () => { - const { claims } = setup({ - leases: [ - { ptyId: 'a', state: 'terminated' }, - { ptyId: 'b', state: 'expired' } - ] - }) - expect(claims.preflight('ssh-1').blockers).toEqual([ - { - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies: [ - { kind: 'terminal-lease', count: 2, names: ['a (terminated)', 'b (expired)'] } - ] - } - ]) - }) - - it('reports an unknown target as a registration blocker', () => { - const { claims } = setup() - expect(claims.preflight('missing').blockers).toEqual([ - { code: 'orcad_migration_target_not_found', category: 'registration' } - ]) - }) - - it('releases only its own claim and clears the provisioning intent', () => { - const { claims, current } = setup() - claims.claim('ssh-1', 'environment-1', 'Managed') - expect(claims.release('ssh-1', 'environment-2')).toBeNull() - expect(claims.release('ssh-1', 'environment-1')).toMatchObject({ - owner: undefined, - orcadProvisioning: undefined - }) - expect(current().owner).toBeUndefined() - }) - - it('flushes without draining to a stable generation', async () => { - const { claims, flush } = setup() - await claims.flush() - expect(flush).toHaveBeenCalledWith({ signal: undefined, drainToStableGeneration: false }) - }) -}) diff --git a/src/main/ssh/ssh-target-orcad-claims.ts b/src/main/ssh/ssh-target-orcad-claims.ts deleted file mode 100644 index cfce055a9ee..00000000000 --- a/src/main/ssh/ssh-target-orcad-claims.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Exclusive managed-orcad ownership of an SSH target. A claimed target is hidden from direct SSH - * surfaces and serves only its environment's tunnel. Only empty targets are claimable, including no - * saved sessions, automations, worktree metadata or terminal leases: moving a direct SSH host's - * state into a managed server is the catalog migration, which this does not do. - */ -import type { Store } from '../persistence' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId -} from '../../shared/managed-orcad-ssh-owner' -import type { - OrcadMigrationBlocker, - OrcadMigrationPreflight -} from '../../shared/orcad-migration-preflight' -import type { SshTarget } from '../../shared/ssh-types' -import { - collectDependentStateBlockers, - dependentStateMessage, - type DependentStateStore -} from './ssh-target-orcad-dependents' - -type ClaimStore = DependentStateStore & - Pick< - Store, - | 'allocateSshTargetGeneration' - | 'flushPendingOrThrowAsync' - | 'getFolderWorkspaces' - | 'getRepos' - | 'getSshTarget' - | 'getSshTargets' - | 'updateSshTarget' - > - -export class SshTargetOrcadClaims { - constructor(private readonly store: ClaimStore) {} - - listTargets(): SshTarget[] { - return this.store.getSshTargets() - } - - /** `environmentId` lets that environment's own claim pass; omit it to see every blocker. */ - preflight(targetId: string, environmentId?: string): OrcadMigrationPreflight { - const target = this.store.getSshTarget(targetId) - if (!target) { - return { - targetId, - targetLabel: null, - claimable: false, - blockers: [{ code: 'orcad_migration_target_not_found', category: 'registration' }] - } - } - if (environmentId && getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId) { - return { targetId, targetLabel: target.label, claimable: true, blockers: [] } - } - const blockers = collectEmptyTargetBlockers(this.store, target) - return { targetId, targetLabel: target.label, claimable: blockers.length === 0, blockers } - } - - /** - * Idempotent for the same environment; the caller makes the claim durable before acting on it. - * A deploy passes its server name so an interrupted claim reads as pending provisioning. - */ - claim(targetId: string, environmentId: string, deployName?: string): SshTarget { - const blocker = this.preflight(targetId, environmentId).blockers[0] - if (blocker) { - throw new Error(orcadTargetBlockerMessage(targetId, blocker)) - } - const target = this.requireTarget(targetId) - const owned = getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId - if (owned && target.generation) { - return target - } - const claimed = this.store.updateSshTarget(targetId, { - owner: createManagedOrcadSshOwner(environmentId), - generation: target.generation ?? this.store.allocateSshTargetGeneration(), - ...(deployName && !target.orcadProvisioning - ? { orcadProvisioning: { requestId: environmentId, name: deployName } } - : {}) - }) - if (!claimed) { - throw new Error(`SSH target "${targetId}" disappeared while it was being reserved.`) - } - return claimed - } - - release(targetId: string, environmentId: string): SshTarget | null { - const target = this.store.getSshTarget(targetId) - if (!target || getManagedOrcadOwnerEnvironmentId(target.owner) !== environmentId) { - return null - } - return this.store.updateSshTarget(targetId, { owner: undefined, orcadProvisioning: undefined }) - } - - /** Ownership must be on disk before a remote host acts on it. */ - flush(signal?: AbortSignal): Promise { - return this.store.flushPendingOrThrowAsync({ signal, drainToStableGeneration: false }) - } - - private requireTarget(targetId: string): SshTarget { - const target = this.store.getSshTarget(targetId) - if (!target) { - throw new Error(`SSH target "${targetId}" not found.`) - } - return target - } -} - -function collectEmptyTargetBlockers(store: ClaimStore, target: SshTarget): OrcadMigrationBlocker[] { - return [ - ...collectTargetCatalogBlockers(store, target), - ...collectDependentStateBlockers(store, target.id) - ] -} - -/** Ownership, the catalog rows the target owns, and its saved port forwards. */ -export function collectTargetCatalogBlockers( - store: Pick, - target: SshTarget -): OrcadMigrationBlocker[] { - const blockers: OrcadMigrationBlocker[] = [] - if (target.owner) { - blockers.push({ - code: 'orcad_migration_target_owned', - category: 'exclusive-ownership', - owner: { ...target.owner } - }) - } - const repositories = store - .getRepos() - .filter((repo) => repo.connectionId === target.id) - .map(({ id, path, displayName, kind }) => ({ id, path, displayName, kind })) - if (repositories.length > 0) { - blockers.push({ - code: 'orcad_migration_direct_ssh_repositories', - category: 'drainable-static-state', - repositories - }) - } - const folderWorkspaces = store - .getFolderWorkspaces() - .filter((workspace) => workspace.connectionId === target.id) - .map(({ id, name, folderPath }) => ({ id, name, folderPath })) - if (folderWorkspaces.length > 0) { - blockers.push({ - code: 'orcad_migration_direct_ssh_folder_workspaces', - category: 'drainable-static-state', - folderWorkspaces - }) - } - if (target.portForwards?.length) { - blockers.push({ - code: 'orcad_migration_saved_port_forwards', - category: 'client-owned-state', - portForwards: target.portForwards.map((portForward) => ({ ...portForward })) - }) - } - return blockers -} - -export function orcadTargetBlockerMessage( - targetId: string, - blocker: OrcadMigrationBlocker -): string { - switch (blocker.code) { - case 'orcad_migration_target_not_found': - return `SSH target "${targetId}" not found.` - case 'orcad_migration_target_owned': - return 'This SSH target is already owned by another managed runtime.' - case 'orcad_migration_direct_ssh_repositories': - case 'orcad_migration_direct_ssh_folder_workspaces': - return 'This SSH target owns repositories or folder workspaces. A managed server can only be created on a host with no direct SSH projects yet; keep this host in direct SSH mode.' - case 'orcad_migration_direct_ssh_terminal_leases': - return 'This SSH target still owns terminal sessions. Close them before converting the host.' - case 'orcad_migration_saved_port_forwards': - return 'This SSH target has saved port forwards. Remove them before converting the host.' - case 'orcad_migration_dependent_state': - return dependentStateMessage(blocker.dependencies) - case 'orcad_migration_dependency_unverifiable': - return `Orca could not read its saved ${blocker.sources.join(', ')} state, so it cannot show this SSH target is unused; the target was left in direct SSH mode.` - } -} diff --git a/src/main/ssh/ssh-target-orcad-dependents-fixture.ts b/src/main/ssh/ssh-target-orcad-dependents-fixture.ts deleted file mode 100644 index 34239d6e54f..00000000000 --- a/src/main/ssh/ssh-target-orcad-dependents-fixture.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { DependentStateStore } from './ssh-target-orcad-dependents' - -/** A store with no client state referencing any SSH target. */ -export function emptyDependentStateStore( - overrides: Partial = {} -): DependentStateStore { - return { - getAllWorktreeMetaForHost: () => ({}), - getSshRemotePtyLeases: () => [], - getWorkspaceSession: () => getDefaultWorkspaceSession(), - getWorkspaceSessionHostIds: () => ['local'], - listAutomations: () => [], - ...overrides - } -} diff --git a/src/main/ssh/ssh-target-orcad-dependents.test.ts b/src/main/ssh/ssh-target-orcad-dependents.test.ts deleted file mode 100644 index ab663c2ed5b..00000000000 --- a/src/main/ssh/ssh-target-orcad-dependents.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { ExecutionHostId } from '../../shared/execution-host' -import { collectDependentStateBlockers } from './ssh-target-orcad-dependents' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' -import { orcadTargetBlockerMessage } from './ssh-target-orcad-claims' - -const HOST: ExecutionHostId = 'ssh:ssh-1' - -function blockersFor(overrides: Parameters[0]) { - return collectDependentStateBlockers(emptyDependentStateStore(overrides), 'ssh-1') -} - -function messageFor(overrides: Parameters[0]): string { - const [blocker] = blockersFor(overrides) - if (!blocker) { - throw new Error('expected a blocker') - } - return orcadTargetBlockerMessage('ssh-1', blocker) -} - -describe('client state that still references an SSH target', () => { - it('finds nothing on an empty profile, or in an untouched session partition', () => { - expect(blockersFor({})).toEqual([]) - expect(blockersFor({ getWorkspaceSessionHostIds: () => ['local', HOST] })).toEqual([]) - }) - - it('blocks on the host session partition and names the fields holding state', () => { - const blockers = blockersFor({ - getWorkspaceSessionHostIds: () => ['local', HOST], - getWorkspaceSession: (hostId) => - hostId === HOST - ? { - ...getDefaultWorkspaceSession(), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only whether the record is empty. - tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only whether the record is empty. - sleepingAgentSessionsByPaneKey: { 'tab-1:leaf': {} } as never - } - : getDefaultWorkspaceSession() - }) - expect(blockers).toEqual([ - { - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies: [ - { - kind: 'workspace-session', - count: 2, - names: ['tabsByWorktree', 'sleepingAgentSessionsByPaneKey'] - } - ] - } - ]) - }) - - it('blocks when the local session points its active workspace at the host', () => { - expect( - messageFor({ - getWorkspaceSession: () => ({ - ...getDefaultWorkspaceSession(), - activeWorkspaceExecutionHostId: HOST - }) - }) - ).toContain('workspace-session ×1 (active workspace)') - }) - - it('blocks on an automation that runs on the host, by name', () => { - const automation = { - name: 'Nightly build', - executionTargetType: 'ssh', - executionTargetId: 'ssh-1' - } - const elsewhere = { name: 'Other', executionTargetType: 'ssh', executionTargetId: 'ssh-2' } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only name and execution target. - const automations = [automation, elsewhere] as never - expect(messageFor({ listAutomations: () => automations })).toContain( - 'automation ×1 (Nightly build)' - ) - }) - - it('blocks on worktree metadata recorded for the host', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only the keys. - const meta = { 'repo-1::/srv/app': {} } as never - expect( - messageFor({ - getAllWorktreeMetaForHost: (hostId) => (hostId === HOST ? meta : {}) - }) - ).toContain('worktree-metadata ×1 (repo-1::/srv/app)') - }) - - it('blocks on a saved terminal lease of any status', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - const leases = [{ ptyId: 'pty-9', state: 'terminated' }] as never - expect(messageFor({ getSshRemotePtyLeases: () => leases })).toContain( - 'terminal-lease ×1 (pty-9 (terminated))' - ) - }) - - it('caps the names it reports but keeps the full count', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - const leases = Array.from({ length: 7 }, (_, i) => ({ - ptyId: `p${i}`, - state: 'expired' - })) as never - const [blocker] = blockersFor({ getSshRemotePtyLeases: () => leases }) - expect(blocker).toMatchObject({ - dependencies: [{ kind: 'terminal-lease', count: 7, names: expect.any(Array) }] - }) - expect( - blocker?.code === 'orcad_migration_dependent_state' && blocker.dependencies[0]?.names - ).toHaveLength(5) - }) - - it('refuses as unverifiable when a store cannot be read, naming the store', () => { - const blockers = blockersFor({ - listAutomations: () => { - throw new Error('automations unreadable') - }, - getAllWorktreeMetaForHost: () => { - throw new Error('meta unreadable') - } - }) - expect(blockers).toEqual([ - { - code: 'orcad_migration_dependency_unverifiable', - category: 'live-or-unverifiable', - sources: ['automation', 'worktree-metadata'] - } - ]) - const [blocker] = blockers - expect(blocker && orcadTargetBlockerMessage('ssh-1', blocker)).toContain( - 'could not read its saved automation, worktree-metadata state' - ) - }) -}) diff --git a/src/main/ssh/ssh-target-orcad-dependents.ts b/src/main/ssh/ssh-target-orcad-dependents.ts deleted file mode 100644 index 9845bb46a80..00000000000 --- a/src/main/ssh/ssh-target-orcad-dependents.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Client state that still references an SSH target. Until the full migration census exists, - * any of it keeps the host from being claimed: hiding the target would strand that state. - */ -import type { Store } from '../persistence' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import { toSshExecutionHostId } from '../../shared/execution-host' -import { - ORCAD_MIGRATION_DEPENDENCY_KINDS, - type OrcadMigrationBlocker, - type OrcadMigrationDependency, - type OrcadMigrationDependencyKind -} from '../../shared/orcad-migration-preflight' -import type { OrcadMigrationManifest } from '../../shared/orcad-migration-manifest' - -export type DependentStateStore = Pick< - Store, - | 'getAllWorktreeMetaForHost' - | 'getSshRemotePtyLeases' - | 'getWorkspaceSession' - | 'getWorkspaceSessionHostIds' - | 'listAutomations' -> - -const MAX_NAMES = 5 - -export function collectDependentStateBlockers( - store: DependentStateStore, - targetId: string -): OrcadMigrationBlocker[] { - const hostId = toSshExecutionHostId(targetId) - const readers: [OrcadMigrationDependencyKind, () => string[]][] = [ - ['workspace-session', () => workspaceSessionReferences(store, hostId)], - [ - 'automation', - () => - store - .listAutomations() - .filter((a) => a.executionTargetType === 'ssh' && a.executionTargetId === targetId) - .map((a) => a.name) - ], - ['worktree-metadata', () => Object.keys(store.getAllWorktreeMetaForHost(hostId))], - // Why every status: a terminated lease is still a saved record pointing at this host. - [ - 'terminal-lease', - () => store.getSshRemotePtyLeases(targetId).map((lease) => `${lease.ptyId} (${lease.state})`) - ] - ] - const dependencies: OrcadMigrationDependency[] = [] - const unreadable: OrcadMigrationDependencyKind[] = [] - for (const [kind, read] of readers) { - let names: string[] - try { - names = read() - } catch { - unreadable.push(kind) - continue - } - if (names.length > 0) { - dependencies.push({ kind, count: names.length, names: names.slice(0, MAX_NAMES) }) - } - } - const blockers: OrcadMigrationBlocker[] = [] - if (dependencies.length > 0) { - blockers.push({ - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies - }) - } - if (unreadable.length > 0) { - blockers.push({ - code: 'orcad_migration_dependency_unverifiable', - category: 'live-or-unverifiable', - sources: unreadable - }) - } - return blockers -} - -/** Kinds with their own blockers (port forwards, terminal leases) are counted elsewhere. */ -const CENSUS_DEPENDENCY_KINDS = ORCAD_MIGRATION_DEPENDENCY_KINDS.filter( - (kind) => kind !== 'saved-port-forward' && kind !== 'terminal-lease' -) - -/** - * The export-aware census: only state that references the target and that this manifest cannot - * carry blocks. A census the store could not take is unverifiable, never an empty one. - */ -export function collectUntransferredDependentBlockers( - store: Pick, - manifest: OrcadMigrationManifest -): OrcadMigrationBlocker[] { - let counts: Record - try { - counts = store.inspectOrcadMigrationUntransferredDependencies(manifest).counts - } catch { - return [ - { - code: 'orcad_migration_dependency_unverifiable', - category: 'live-or-unverifiable', - sources: [...CENSUS_DEPENDENCY_KINDS] - } - ] - } - const dependencies = CENSUS_DEPENDENCY_KINDS.filter((kind) => counts[kind] > 0).map((kind) => ({ - kind, - count: counts[kind] - })) - return dependencies.length > 0 - ? [{ code: 'orcad_migration_dependent_state', category: 'client-owned-state', dependencies }] - : [] -} - -/** Non-default fields of the host's session partition, plus a local session pointed at the host. */ -function workspaceSessionReferences(store: DependentStateStore, hostId: string): string[] { - const references: string[] = [] - if (store.getWorkspaceSession().activeWorkspaceExecutionHostId === hostId) { - references.push('active workspace') - } - if (!store.getWorkspaceSessionHostIds().some((id) => id === hostId)) { - return references - } - const defaults: Record = { ...getDefaultWorkspaceSession() } - for (const [field, value] of Object.entries(store.getWorkspaceSession(hostId))) { - if (!isEmptyValue(value) && JSON.stringify(value) !== JSON.stringify(defaults[field])) { - references.push(field) - } - } - return references -} - -function isEmptyValue(value: unknown): boolean { - if (value === null || value === undefined) { - return true - } - if (Array.isArray(value)) { - return value.length === 0 - } - return typeof value === 'object' && Object.keys(value).length === 0 -} - -export function dependentStateMessage(dependencies: OrcadMigrationDependency[]): string { - const parts = dependencies.map( - ({ kind, count, names }) => `${kind} ×${count}${names?.length ? ` (${names.join(', ')})` : ''}` - ) - return ( - 'This SSH target is still referenced by saved Orca state that a managed server cannot take ' + - `over yet: ${parts.join('; ')}. Remove it, or keep this host in direct SSH mode.` - ) -} diff --git a/src/main/ssh/ssh-target-orcad-preflight.test.ts b/src/main/ssh/ssh-target-orcad-preflight.test.ts deleted file mode 100644 index 65905d9a50c..00000000000 --- a/src/main/ssh/ssh-target-orcad-preflight.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { SshRemotePtyLease, SshTarget } from '../../shared/ssh-types' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' -import { Store } from '../persistence/loading-store/store' -import { - preflightOrcadMigrationExport, - type OrcadMigrationPreflightStore -} from './ssh-target-orcad-preflight' - -const TARGET: SshTarget = { - id: 'ssh-prod', - label: 'Production', - host: 'prod.example.com', - port: 22, - username: 'deploy', - generation: 2 -} - -const directories: string[] = [] -afterEach(async () => { - await closeTestStores() - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -function preflightStore( - configure: (store: Store) => void, - overrides: Partial = {} -): OrcadMigrationPreflightStore { - const directory = mkdtempSync(join(tmpdir(), 'orcad-export-preflight-')) - directories.push(directory) - const store = createSqliteTestStore(Store, { dataFile: join(directory, 'orca-data.json') }) - configure(store) - return { - getSshTarget: (id) => store.getSshTarget(id), - getSshRemotePtyLeases: (id) => store.getSshRemotePtyLeases(id), - getRepos: () => store.getRepos(), - getFolderWorkspaces: () => store.getFolderWorkspaces(), - getProjectGroups: () => store.getProjectGroups(), - collectOrcadMigrationSourceDormantState: (...args) => - store.collectOrcadMigrationSourceDormantState(...args), - inspectOrcadMigrationUntransferredDependencies: (manifest) => - store.inspectOrcadMigrationUntransferredDependencies(manifest), - ...overrides - } -} - -function withRepo(store: Store): void { - store.addSshTarget(TARGET) - store.addRepo({ - id: 'repo-1', - path: '/srv/app', - displayName: 'App', - badgeColor: '#737373', - addedAt: 1, - kind: 'git', - connectionId: TARGET.id - }) -} - -describe('migration export preflight', () => { - it('drains the catalog the target owns instead of blocking on it', () => { - const result = preflightOrcadMigrationExport(preflightStore(withRepo), TARGET.id) - expect(result.claimable).toBe(true) - expect(result.blockers.map((blocker) => blocker.code)).toEqual([ - 'orcad_migration_direct_ssh_repositories' - ]) - }) - - it('blocks on a live relay terminal, which cannot move', () => { - const lease: SshRemotePtyLease = { - targetId: TARGET.id, - ptyId: 'pty-1', - state: 'attached', - createdAt: 1, - updatedAt: 1 - } - const result = preflightOrcadMigrationExport( - preflightStore(withRepo, { getSshRemotePtyLeases: () => [lease] }), - TARGET.id - ) - expect(result.claimable).toBe(false) - expect(result.blockers.map((blocker) => blocker.code)).toContain( - 'orcad_migration_direct_ssh_terminal_leases' - ) - }) - - it('blocks on dependent state the manifest cannot carry', () => { - const result = preflightOrcadMigrationExport( - preflightStore(withRepo, { - inspectOrcadMigrationUntransferredDependencies: () => ({ - totalCount: 2, - counts: { - automation: 2, - 'automation-run': 0, - 'mobile-tab-selection': 0, - 'retired-worktree-name': 0, - 'saved-port-forward': 0, - 'sparse-preset': 0, - 'terminal-lease': 0, - 'terminal-recovery': 0, - 'ui-routing': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - 'worktree-lineage': 0, - 'worktree-metadata': 0 - } - }) - }), - TARGET.id - ) - expect(result.claimable).toBe(false) - expect(result.blockers).toContainEqual({ - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies: [{ kind: 'automation', count: 2 }] - }) - }) - - it('treats a census the store could not take as unverifiable, never empty', () => { - const result = preflightOrcadMigrationExport( - preflightStore(withRepo, { - inspectOrcadMigrationUntransferredDependencies: () => { - throw new Error('state unreadable') - } - }), - TARGET.id - ) - expect(result.claimable).toBe(false) - expect(result.blockers.map((blocker) => blocker.code)).toContain( - 'orcad_migration_dependency_unverifiable' - ) - }) - - it('refuses a target another runtime owns, and passes the owner its own claim', () => { - const owned = preflightStore((store) => - store.addSshTarget({ ...TARGET, owner: createManagedOrcadSshOwner('env-1') }) - ) - expect(preflightOrcadMigrationExport(owned, TARGET.id).claimable).toBe(false) - expect(preflightOrcadMigrationExport(owned, TARGET.id, 'env-1').claimable).toBe(true) - }) - - it('reports an unknown target', () => { - expect( - preflightOrcadMigrationExport( - preflightStore(() => {}), - 'missing' - ) - ).toMatchObject({ - claimable: false, - blockers: [{ code: 'orcad_migration_target_not_found' }] - }) - }) -}) diff --git a/src/main/ssh/ssh-target-orcad-preflight.ts b/src/main/ssh/ssh-target-orcad-preflight.ts deleted file mode 100644 index 14a2bce0270..00000000000 --- a/src/main/ssh/ssh-target-orcad-preflight.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Can this relay-hosted SSH target's state be exported to a managed orcad? - * - * Unlike claiming an empty target, export carries the target's repositories, folder - * workspaces and the dormant state the manifest can represent, so those drain rather than block. - * What blocks is what cannot move: another owner, live terminal leases, and dependent state the - * manifest cannot carry. Read-only: building the manifest here exports nothing. - */ -import type { Store } from '../persistence' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { - OrcadMigrationBlocker, - OrcadMigrationPreflight -} from '../../shared/orcad-migration-preflight' -import { - createOrcadMigrationManifest, - type OrcadMigrationExportStore -} from './orcad-migration-manifest-export' -import { collectTargetCatalogBlockers } from './ssh-target-orcad-claims' -import { collectUntransferredDependentBlockers } from './ssh-target-orcad-dependents' - -export type OrcadMigrationPreflightStore = OrcadMigrationExportStore & - Pick< - Store, - 'getSshTarget' | 'getSshRemotePtyLeases' | 'inspectOrcadMigrationUntransferredDependencies' - > - -export function preflightOrcadMigrationExport( - store: OrcadMigrationPreflightStore, - targetId: string, - environmentId?: string -): OrcadMigrationPreflight { - const target = store.getSshTarget(targetId) - if (!target) { - return { - targetId, - targetLabel: null, - claimable: false, - blockers: [{ code: 'orcad_migration_target_not_found', category: 'registration' }] - } - } - if (environmentId && getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId) { - return { targetId, targetLabel: target.label, claimable: true, blockers: [] } - } - const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)] - const terminalLeases = store - .getSshRemotePtyLeases(targetId) - .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') - .map(({ ptyId, worktreeId, tabId, leafId, state, updatedAt }) => ({ - ptyId, - worktreeId, - tabId, - leafId, - state, - updatedAt - })) - if (terminalLeases.length > 0) { - // A relay PTY cannot move to orcad; its work must finish first. - blockers.push({ - code: 'orcad_migration_direct_ssh_terminal_leases', - category: 'live-or-unverifiable', - terminalLeases - }) - } - blockers.push( - ...collectUntransferredDependentBlockers(store, createOrcadMigrationManifest(store, target)) - ) - return { - targetId, - targetLabel: target.label, - // Exported catalog rows and saved port forwards (which stay with the source) do not block. - claimable: blockers.every( - (blocker) => - blocker.category === 'drainable-static-state' || - blocker.code === 'orcad_migration_saved_port_forwards' - ), - blockers - } -} diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts index aef003025cc..9959e8072a6 100644 --- a/src/main/ssh/ssh-target-registry.ts +++ b/src/main/ssh/ssh-target-registry.ts @@ -90,7 +90,6 @@ export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexe } let registeredGetSshConnectionManager: (() => SshConnectionManager | null) | null = null -let registeredHasDirectSshAuthority: ((targetId: string) => boolean) | null = null export function setSshConnectionManagerResolver( resolve: (() => SshConnectionManager | null) | null @@ -109,14 +108,3 @@ export function setSshConnectionManagerResolver( export function getSshConnectionManager(): SshConnectionManager | null { return registeredGetSshConnectionManager?.() ?? null } - -export function setDirectSshAuthorityResolver( - resolve: ((targetId: string) => boolean) | null -): void { - registeredHasDirectSshAuthority = resolve -} - -/** Whether this process currently holds the target's relay session; false when unregistered. */ -export function hasRegisteredDirectSshAuthority(targetId: string): boolean { - return registeredHasDirectSshAuthority?.(targetId) ?? false -} diff --git a/src/main/ssh/ssh-transport-close-ledger.test.ts b/src/main/ssh/ssh-transport-close-ledger.test.ts deleted file mode 100644 index b126c9cb0b1..00000000000 --- a/src/main/ssh/ssh-transport-close-ledger.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { SshTransportCloseLedger } from './ssh-transport-close-ledger' - -function fixture() { - const ledger = new SshTransportCloseLedger() - const allocate = () => { - const client = new EventEmitter() - ledger.track(client) - return client - } - return { ledger, allocate } -} - -it('waits for all allocated clients, including failed authentication attempts', async () => { - const { ledger, allocate } = fixture() - const failed = allocate() - const successful = allocate() - const done = vi.fn() - const draining = ledger.drain(new AbortController().signal).then(done) - failed.emit('end') - successful.emit('close') - await Promise.resolve() - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - expect(failed.listenerCount('close')).toBe(0) - expect(successful.listenerCount('close')).toBe(0) -}) - -it('remembers closure before drain and does not accumulate listeners', async () => { - const { ledger, allocate } = fixture() - for (let i = 0; i < 100; i++) { - const client = allocate() - ledger.track(client) - expect(client.listenerCount('close')).toBe(1) - client.emit('close') - expect(client.listenerCount('close')).toBe(0) - } - await ledger.drain(new AbortController().signal) - await ledger.drain(new AbortController().signal) -}) - -it('retains physical closure evidence across canceled waits', async () => { - const { ledger, allocate } = fixture() - const client = allocate() - const controller = new AbortController() - const draining = ledger.drain(controller.signal) - controller.abort() - await expect(draining).rejects.toThrow() - expect(client.listenerCount('close')).toBe(1) - const retry = ledger.drain(new AbortController().signal) - client.emit('close') - await retry - expect(client.listenerCount('close')).toBe(0) -}) - -it('refuses new outstanding allocations made after the drain snapshot', async () => { - const { ledger, allocate } = fixture() - const original = allocate() - const draining = ledger.drain(new AbortController().signal) - const late = allocate() - original.emit('close') - await expect(draining).rejects.toThrow('ssh_client_close_allocations_changed') - late.emit('close') - await ledger.drain(new AbortController().signal) -}) diff --git a/src/main/ssh/ssh-transport-close-ledger.ts b/src/main/ssh/ssh-transport-close-ledger.ts deleted file mode 100644 index b9721ea71fc..00000000000 --- a/src/main/ssh/ssh-transport-close-ledger.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { EventEmitter } from 'node:events' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Authentication settlement does not establish physical socket closure. */ -export class SshTransportCloseLedger { - private readonly pending = new Map>() - - constructor(private readonly onChange: () => void = () => {}) {} - - isClosed(): boolean { - return this.pending.size === 0 - } - - track(client: EventEmitter): void { - if (this.pending.has(client)) { - return - } - let resolveClose!: () => void - const closed = new Promise((resolve) => { - resolveClose = resolve - }) - const onClose = (): void => { - client.off('close', onClose) - this.pending.delete(client) - resolveClose() - this.onChange() - } - this.pending.set(client, closed) - client.on('close', onClose) - } - - /** Caller must fence new allocations before taking this snapshot. */ - async drain(signal: AbortSignal): Promise { - await waitForPromiseWithSignal(Promise.all(this.pending.values()), signal) - if (this.pending.size !== 0) { - throw new Error('ssh_client_close_allocations_changed') - } - } -} diff --git a/src/main/ssh/ssh-transport-close-observation.test.ts b/src/main/ssh/ssh-transport-close-observation.test.ts deleted file mode 100644 index c12950110b1..00000000000 --- a/src/main/ssh/ssh-transport-close-observation.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { observeSshTransportClose } from './ssh-transport-close-observation' - -it('resolves only after every observed resource emits close', async () => { - const client = new EventEmitter() - client.on('error', () => {}) - const proxy = new EventEmitter() - const observation = observeSshTransportClose([client, proxy]) - const done = vi.fn() - const waiting = observation.wait(new AbortController().signal).then(done) - client.emit('end') - client.emit('error', new Error('reset by peer')) - client.emit('close') - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - proxy.emit('close') - await waiting - expect(done).toHaveBeenCalledOnce() -}) - -it('cancels only the wait and removes its listeners on dispose', async () => { - const client = new EventEmitter() - client.on('error', () => {}) - const observation = observeSshTransportClose([client]) - const controller = new AbortController() - const waiting = observation.wait(controller.signal) - controller.abort(new Error('caller gave up')) - await expect(waiting).rejects.toThrow('caller gave up') - expect(client.listenerCount('close')).toBe(1) - observation.dispose() - expect(client.listenerCount('close')).toBe(0) -}) diff --git a/src/main/ssh/ssh-transport-close-observation.ts b/src/main/ssh/ssh-transport-close-observation.ts deleted file mode 100644 index 5f604303d42..00000000000 --- a/src/main/ssh/ssh-transport-close-observation.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { EventEmitter } from 'node:events' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Register before initiating close; 'end', 'error' and destroy requests are not closure. */ -export function observeSshTransportClose(resources: readonly EventEmitter[]) { - const listeners: { resource: EventEmitter; close: () => void }[] = [] - const closed = Promise.all( - resources.map( - (resource) => - new Promise((resolve) => { - const close = () => resolve() - listeners.push({ resource, close }) - resource.once('close', close) - }) - ) - ) - return { - wait: (signal: AbortSignal) => waitForPromiseWithSignal(closed, signal), - dispose: () => { - for (const { resource, close } of listeners) { - resource.removeListener('close', close) - } - } - } -} diff --git a/src/main/ssh/ssh-upload-session-lifetime.test.ts b/src/main/ssh/ssh-upload-session-lifetime.test.ts deleted file mode 100644 index adaa13642ff..00000000000 --- a/src/main/ssh/ssh-upload-session-lifetime.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import type { FileUploadSession } from '../providers/filesystem-provider-contract' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { openTrackedSshUploadSession } from './ssh-upload-session-lifetime' - -const signal = () => new AbortController().signal -const makeSession = (): FileUploadSession => ({ - uploadFile: vi.fn(async () => undefined), - close: vi.fn() -}) - -it('holds an idle session until explicit close and closes the underlying session once', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const fence = ledger.fenceForReset() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - session.close() - session.close() - await fence.drain(signal()) - expect(underlying.close).toHaveBeenCalledTimes(1) - await expect(session.uploadFile('source', 'destination')).rejects.toThrow( - 'ssh_connection_work_session_closed' - ) - expect(underlying.uploadFile).not.toHaveBeenCalled() -}) - -it('waits for every concurrent upload after close', async () => { - const ledger = new SshConnectionWorkLedger() - const first = Promise.withResolvers() - const second = Promise.withResolvers() - const underlying = makeSession() - underlying.uploadFile = vi - .fn() - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const uploadingFirst = session.uploadFile('a', 'b') - const uploadingSecond = session.uploadFile('c', 'd') - const fence = ledger.fenceForReset() - session.close() - first.resolve() - await uploadingFirst - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - second.resolve() - await uploadingSecond - await fence.drain(signal()) - fence.assertDrained() -}) - -it('allows admitted multi-file sessions to continue after fencing but refuses new sessions', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - underlying.uploadFile = vi.fn(async () => { - await ledger.run(async () => undefined) - }) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - await session.uploadFile('a', 'b') - const fence = ledger.fenceForReset() - const unopened = vi.fn(async () => makeSession()) - await expect(openTrackedSshUploadSession(ledger, unopened)).rejects.toThrow( - 'ssh_connection_work_admission_closed' - ) - expect(unopened).not.toHaveBeenCalled() - await session.uploadFile('c', 'd', { exclusive: true }) - expect(underlying.uploadFile).toHaveBeenLastCalledWith('c', 'd', { exclusive: true }) - session.close() - await fence.drain(signal()) -}) - -it('retains pending factory admission across the fence and independently tracks physical channels', async () => { - const ledger = new SshConnectionWorkLedger() - const release = Promise.withResolvers() - let channel!: ReturnType - const opening = openTrackedSshUploadSession(ledger, async () => { - await release.promise - await ledger.run(async () => { - channel = ledger.beginChannelOpen() - channel.bind(new EventEmitter()) - }) - return makeSession() - }) - const fence = ledger.fenceForReset() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - release.resolve() - const session = await opening - session.close() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - channel.close() - await fence.drain(signal()) -}) - -it('retains failed factories that settle after fencing', async () => { - const ledger = new SshConnectionWorkLedger() - const factory = Promise.withResolvers() - const failure = new Error('factory failed') - const opening = openTrackedSshUploadSession(ledger, () => factory.promise).catch( - (error: unknown) => error - ) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - factory.reject(failure) - expect(await opening).toBe(failure) - expect(await draining).toBe(failure) - expect(fence.assertDrained).toThrow(failure) -}) - -it('wakes reset on upload failure even while the held session remains open', async () => { - const ledger = new SshConnectionWorkLedger() - const upload = Promise.withResolvers() - const underlying = makeSession() - underlying.uploadFile = () => upload.promise - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const uploading = session.uploadFile('a', 'b').catch((error: unknown) => error) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - const failure = new Error('upload failed') - upload.reject(failure) - expect(await uploading).toBe(failure) - expect(await draining).toBe(failure) - session.close() - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('retains close failure and refuses subsequent uploads or repeated physical close', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const failure = new Error('close failed') - underlying.close = vi.fn(() => { - throw failure - }) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const fence = ledger.fenceForReset() - expect(session.close).toThrow(failure) - session.close() - expect(underlying.close).toHaveBeenCalledTimes(1) - await expect(session.uploadFile('a', 'b')).rejects.toThrow('ssh_connection_work_session_closed') - await expect(fence.drain(signal())).rejects.toBe(failure) - expect(fence.assertDrained).toThrow(failure) -}) - -it('preserves close uncertainty raised before reset starts', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const failure = new Error('close failed before reset') - underlying.close = () => { - throw failure - } - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - expect(session.close).toThrow(failure) - const fence = ledger.fenceForReset() - await expect(fence.drain(signal())).rejects.toBe(failure) - expect(fence.assertDrained).toThrow() -}) - -it('isolates sessions, admission, and failures between connection ledgers', async () => { - const first = new SshConnectionWorkLedger() - const second = new SshConnectionWorkLedger() - const failed = makeSession() - const failure = new Error('first connection failed') - failed.close = () => { - throw failure - } - const firstSession = await openTrackedSshUploadSession(first, async () => failed) - const firstFence = first.fenceForReset() - expect(firstSession.close).toThrow(failure) - const secondSession = await openTrackedSshUploadSession(second, async () => makeSession()) - const secondFence = second.fenceForReset() - await secondSession.uploadFile('a', 'b') - secondSession.close() - await secondFence.drain(signal()) - secondFence.assertDrained() - await expect(firstFence.drain(signal())).rejects.toBe(failure) -}) - -it('does not let stale factory callbacks reuse the held-session admission scope', async () => { - const ledger = new SshConnectionWorkLedger() - const release = Promise.withResolvers() - let stale!: Promise - const session = await openTrackedSshUploadSession(ledger, async () => { - stale = release.promise - .then(() => ledger.run(async () => undefined)) - .catch((error: unknown) => error) - return makeSession() - }) - const fence = ledger.fenceForReset() - release.resolve() - expect(await stale).toMatchObject({ message: 'ssh_connection_work_admission_closed' }) - session.close() - await fence.drain(signal()) -}) diff --git a/src/main/ssh/ssh-upload-session-lifetime.ts b/src/main/ssh/ssh-upload-session-lifetime.ts deleted file mode 100644 index bbbd6f3119c..00000000000 --- a/src/main/ssh/ssh-upload-session-lifetime.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { FileUploadSession } from '../providers/filesystem-provider-contract' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -export async function openTrackedSshUploadSession( - ledger: SshConnectionWorkLedger, - open: () => Promise -): Promise { - const lifetime = ledger.beginSession() - let session: FileUploadSession - try { - session = await lifetime.run(open) - } catch (error) { - lifetime.close(error) - throw error - } - let closed = false - return { - uploadFile: (...args) => lifetime.run(() => session.uploadFile(...args)), - close: () => { - if (closed) { - return - } - closed = true - try { - session.close() - } catch (error) { - lifetime.close(error) - throw error - } - lifetime.close() - } - } -} diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index 15f099a5f88..eab96245ba5 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -96,10 +96,6 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { }, write(chunk, encoding, cb) { proc.stdin!.write(chunk, encoding, cb) - }, - final(cb) { - // EOF must reach the remote reader, not just this local facade. - proc.stdin!.end(cb) } }) const channel = duplex as unknown as SystemSshCommandChannel diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index eaefd4d367d..c761ccf0409 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -55,9 +55,8 @@ describe('headless PTY registry hydration ordering', () => { it('starts the orcad hook owner after Store hydration and before daemon PTY recovery', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') - // Each runtime resource registers its own cleanup; the hook owner's must precede its start. - const cleanup = source.indexOf('registerCleanup(') - const hookStop = source.indexOf('registerCleanup(() => agentHookServer.stop())', cleanup) + const cleanup = source.indexOf('registerCleanup(async () => {') + const hookStop = source.indexOf('agentHookServer.stop()', cleanup) const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const hookStart = source.indexOf('await agentHookServer.start(', store) const daemon = source.indexOf('await startOrcadDaemon()', hookStart) @@ -65,7 +64,7 @@ describe('headless PTY registry hydration ordering', () => { const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', hookEnv) expect(cleanup).toBeGreaterThanOrEqual(0) - expect(hookStop).toBeGreaterThanOrEqual(cleanup) + expect(hookStop).toBeGreaterThan(cleanup) expect(store).toBeGreaterThan(hookStop) expect(hookStart).toBeGreaterThan(store) expect(daemon).toBeGreaterThan(hookStart) diff --git a/src/main/terminal-scrollback-snapshot-async-migration.ts b/src/main/terminal-scrollback-snapshot-async-migration.ts index 6916c8de17a..1768c0a20a1 100644 --- a/src/main/terminal-scrollback-snapshot-async-migration.ts +++ b/src/main/terminal-scrollback-snapshot-async-migration.ts @@ -40,8 +40,7 @@ export async function migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( export async function deleteRemovedTerminalScrollbackSnapshotsAsync( prior: WorkspaceSessionState | undefined, next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage, - retainedRefs: ReadonlySet = new Set() + storage?: TerminalScrollbackSnapshotStorage ): Promise { if (!prior) { return @@ -49,7 +48,7 @@ export async function deleteRemovedTerminalScrollbackSnapshotsAsync( const nextRefs = collectTerminalScrollbackSnapshotRefs(next) await Promise.all( [...collectTerminalScrollbackSnapshotRefs(prior)] - .filter((ref) => !nextRefs.has(ref) && !retainedRefs.has(ref)) + .filter((ref) => !nextRefs.has(ref)) .map((ref) => deleteTerminalScrollbackSnapshot(ref, storage)) ) } diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 352edb5ebb4..70d4be18002 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -3,7 +3,6 @@ import { closeSync, mkdirSync, openSync, - readFileSync, readSync, renameSync, rmSync, @@ -35,9 +34,7 @@ export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): stri return join(dirname(dataFile), SNAPSHOT_DIR_NAME) } -export function getTerminalScrollbackSnapshotRoot( - storage?: TerminalScrollbackSnapshotStorage -): string { +function getSnapshotRoot(storage?: TerminalScrollbackSnapshotStorage): string { return storage?.snapshotRoot ?? getLegacySnapshotRoot() } @@ -54,7 +51,7 @@ function snapshotPath(ref: string, snapshotRoot: string): string | null { } function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStorage): string[] { - const primaryRoot = getTerminalScrollbackSnapshotRoot(storage) + const primaryRoot = getSnapshotRoot(storage) const primaryPath = snapshotPath(ref, primaryRoot) if (!primaryPath) { return [] @@ -67,32 +64,6 @@ function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStor return fallbackPath ? [primaryPath, fallbackPath] : [primaryPath] } -export function getTerminalScrollbackSnapshotPath( - ref: string, - storage?: TerminalScrollbackSnapshotStorage -): string | null { - return snapshotPath(ref, getTerminalScrollbackSnapshotRoot(storage)) -} - -/** The stored bytes for `ref`, bounded by the store limit; `null` when absent or out of bounds. */ -export function readTerminalScrollbackStoredBytesSync( - ref: string, - storage?: TerminalScrollbackSnapshotStorage -): Buffer | null { - for (const path of snapshotReadPaths(ref, storage)) { - try { - const size = statSync(path).size - if (size <= 0 || size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) { - return null - } - return readFileSync(path) - } catch { - // Try the profile fallback when the primary snapshot is absent. - } - } - return null -} - function trailingUtf8Bytes(value: string, maxBytes: number): Buffer { const bytes = Buffer.from(value, 'utf-8') if (bytes.length <= maxBytes) { @@ -135,7 +106,7 @@ export function writeTerminalScrollbackSnapshotSync(args: { return null } const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) - const snapshotRoot = getTerminalScrollbackSnapshotRoot(args.storage) + const snapshotRoot = getSnapshotRoot(args.storage) const path = snapshotPath(ref, snapshotRoot) if (!path) { return null @@ -173,7 +144,7 @@ export async function writeTerminalScrollbackSnapshot(args: { return null } const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) - const snapshotRoot = getTerminalScrollbackSnapshotRoot(args.storage) + const snapshotRoot = getSnapshotRoot(args.storage) const path = snapshotPath(ref, snapshotRoot) if (!path) { return null diff --git a/src/main/worktree-retirement-namespace.ts b/src/main/worktree-retirement-namespace.ts index 7f45ed72e92..11abc8a7c1b 100644 --- a/src/main/worktree-retirement-namespace.ts +++ b/src/main/worktree-retirement-namespace.ts @@ -50,7 +50,7 @@ export function retirementNamespaceKey(hostIdentity: string, probePath: string): /** Rewrites a key's host identity, keeping its workspace-path half. Returns null when the key is * not under `fromIdentity` or the swap is a no-op. */ -export function swapRetirementNamespaceHost( +function swapRetirementNamespaceHost( namespaceKey: string, fromIdentity: string, toIdentity: string diff --git a/src/relay/agent-exec-disposal.test.ts b/src/relay/agent-exec-disposal.test.ts deleted file mode 100644 index 6e770ff5b9e..00000000000 --- a/src/relay/agent-exec-disposal.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { execFile } from 'node:child_process' -import type * as ChildProcess from 'node:child_process' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { AgentExecHandler } from './agent-exec-handler' -import { RELAY_AGENT_CLOSE_DEADLINE_MS } from './relay-agent-process-lifetime' -import { createFakeChild, requestContext } from './agent-exec-handler-test-harness' -import type { MethodHandler, RelayDispatcher } from './dispatcher' - -// Why an untyped mock: the fake child stubs only what AgentExecHandler reads from a ChildProcess. -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn: (...args: unknown[]) => spawnMock(...args), - execFile: vi.fn() -})) - -function fixture() { - const methods = new Map() - const handler = new AgentExecHandler( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only registers request methods. - { - onRequest: (name: string, method: MethodHandler) => methods.set(name, method) - } as unknown as RelayDispatcher - ) - const exec = (params: Record = {}) => - methods.get('agent.execNonInteractive')!( - { binary: 'agent', timeoutMs: 1000, ...params }, - requestContext() - ) - return { handler, exec } -} - -beforeEach(() => { - vi.useFakeTimers() - spawnMock.mockReset() - vi.mocked(execFile).mockReset() -}) -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -it('waits for every child close, including commands without a cwd and replaced lanes', async () => { - const f = fixture() - const children = [createFakeChild(), createFakeChild(), createFakeChild()] - for (const child of children) { - spawnMock.mockReturnValueOnce(child) - } - const requests = [f.exec(), f.exec({ cwd: '/repo' }), f.exec({ cwd: '/repo' })] - let finished = false - const disposal = f.handler.dispose().then(() => { - finished = true - }) - await Promise.resolve() - expect(finished).toBe(false) - if (process.platform === 'win32') { - expect(execFile).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - } else { - for (const child of children) { - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - } - } - children[0].emit('close', null) - children[2].emit('close', null) - await Promise.resolve() - expect(finished).toBe(false) - children[1].emit('close', null) - await disposal - expect(finished).toBe(true) - await Promise.all(requests) -}) - -it('refuses execution once disposal begins and after it completes', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec() - const disposal = f.handler.dispose() - await expect(f.exec()).rejects.toThrow() - expect(spawnMock).toHaveBeenCalledTimes(1) - child.emit('close', null) - await Promise.all([request, disposal]) - await expect(f.exec()).rejects.toThrow() - expect(spawnMock).toHaveBeenCalledTimes(1) -}) - -it('retains timed-out children until close rather than treating RPC settlement as exit', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec({ cwd: '/repo' }) - await vi.advanceTimersByTimeAsync(1000) - await expect(request).resolves.toMatchObject({ timedOut: true }) - let finished = false - const disposal = f.handler.dispose().then(() => { - finished = true - }) - await Promise.resolve() - expect(finished).toBe(false) - child.emit('error', new Error('late child error')) - await Promise.resolve() - expect(finished).toBe(false) - child.emit('close', null) - await disposal - expect(finished).toBe(true) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) -}) - -it('resolves disposal with no children or only already-closed children', async () => { - await fixture().handler.dispose() - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec() - child.emit('close', 0) - await request - await f.handler.dispose() - expect(child.kill).not.toHaveBeenCalled() -}) - -it('rejects within the close deadline and re-checks without re-killing on retry', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec({ timeoutMs: 60_000 }) - const signals = () => - process.platform === 'win32' - ? vi.mocked(execFile).mock.calls.length - : child.kill.mock.calls.length - const first = f.handler.dispose() - const firstOutcome = first.then( - () => 'resolved', - (error: Error) => error.message - ) - expect(signals()).toBe(1) - await vi.advanceTimersByTimeAsync(RELAY_AGENT_CLOSE_DEADLINE_MS) - await expect(firstOutcome).resolves.toBe('relay_agent_execution_shutdown_incomplete') - const retry = f.handler.dispose() - expect(signals()).toBe(1) - child.emit('close', null) - await expect(retry).resolves.toBeUndefined() - await request -}) diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index 1cb2820b1ec..87a2ef0403b 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -523,10 +523,6 @@ describe('AgentExecHandler', () => { } expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) - // Shutdown keeps tracking the child until it physically closes. - expect(child.listenerCount('error')).toBe(1) - expect(child.listenerCount('close')).toBe(1) - child.emit('close', null) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) } finally { diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index 4314dbbc6a7..152e8bd72fb 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -6,7 +6,6 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { terminateRelaySubprocessTree } from './subprocess-tree-termination' -import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime' const DEFAULT_TIMEOUT_MS = 60_000 const MAX_TIMEOUT_MS = 5 * 60 * 1000 @@ -110,7 +109,6 @@ type ExecResult = { * and a clean exit code instead of an interactive session. */ export class AgentExecHandler { - private readonly processLifetime = new RelayAgentProcessLifetime() // Why: commit-message and PR-field generation can run together for one cwd; // operation lanes let cancel target only the user-visible job that stopped. private inFlightByLane = new Map() @@ -126,10 +124,6 @@ export class AgentExecHandler { dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams)) } - dispose(): Promise { - return this.processLifetime.dispose() - } - private async cancel(params: CancelParams): Promise<{ canceled: boolean }> { const cwd = typeof params.cwd === 'string' ? params.cwd : '' const entry = this.inFlightByLane.get(this.laneKey(cwd, params.operation)) @@ -141,7 +135,6 @@ export class AgentExecHandler { } private async exec(params: ExecParams, context?: RequestContext): Promise { - this.processLifetime.assertAdmission() const binary = typeof params.binary === 'string' ? params.binary : '' if (!binary) { throw new Error('agent.execNonInteractive: binary is required') @@ -195,7 +188,6 @@ export class AgentExecHandler { return } - this.processLifetime.track(child) let stdout = '' let stderr = '' let stdoutBytes = 0 diff --git a/src/relay/dispatcher-client-state.ts b/src/relay/dispatcher-client-state.ts index 80d1990085e..710a704e4d8 100644 --- a/src/relay/dispatcher-client-state.ts +++ b/src/relay/dispatcher-client-state.ts @@ -1,6 +1,5 @@ import type { DecodedFrame, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse } from './protocol' import { ClientRequestAborts } from './client-request-aborts' -import { RelayWorkAdmission } from './relay-work-admission' import type { PtyConsumerCloseCause } from '../shared/pty-consumer-session-contract' import { LegacyRelayPublicationLedger, @@ -20,12 +19,10 @@ import type { PtyDataPublicationAdmission, RelayClient, RelayClientSessionIdentity, - RelayClientSourceOptions, - RequestContext + RelayClientSourceOptions } from './dispatcher-contract' export abstract class RelayDispatcherClientState { - protected readonly workAdmission = new RelayWorkAdmission() protected readonly primaryClient: RelayClient protected readonly clients = new Map() protected requestHandlers = new Map() @@ -60,17 +57,7 @@ export abstract class RelayDispatcherClientState { } onRequest(method: string, handler: MethodHandler): void { - this.requestHandlers.set(method, (params, context) => - this.workAdmission.run(method, context, () => handler(params, context)) - ) - } - - beginWorkDrain(exclude?: RequestContext): Promise { - return this.workAdmission.beginDrain(exclude) - } - - assertActiveWorkContext(context: RequestContext): void { - this.workAdmission.assertActiveContext(context) + this.requestHandlers.set(method, handler) } // Why it throws: this is a single slot, so a second registration silently shadows the @@ -81,9 +68,7 @@ export abstract class RelayDispatcherClientState { if (this.notificationHandlers.has(method)) { throw new Error(`Notification handler for ${method} is already registered`) } - this.notificationHandlers.set(method, (params, context) => - this.workAdmission.runNotification(method, context, () => handler(params, context)) - ) + this.notificationHandlers.set(method, handler) } onClientDetached(listener: (clientId: number, cause: PtyConsumerCloseCause) => void): () => void { diff --git a/src/relay/dispatcher-client-writer.ts b/src/relay/dispatcher-client-writer.ts index 42feaa68218..514ee47b8da 100644 --- a/src/relay/dispatcher-client-writer.ts +++ b/src/relay/dispatcher-client-writer.ts @@ -56,10 +56,6 @@ export class DispatcherClientWriter { return this.admission.retainedProducerBytes } - get supportsWriteCallback(): boolean { - return this.sink.supportsWriteCallback - } - get producerFrameCapacity(): number { return this.sink.producerFrameCapacity } diff --git a/src/relay/dispatcher-contract.ts b/src/relay/dispatcher-contract.ts index 679d4657e62..23c62431d2c 100644 --- a/src/relay/dispatcher-contract.ts +++ b/src/relay/dispatcher-contract.ts @@ -9,8 +9,6 @@ import type { DispatcherClientWriter, SinkWriteSettlement } from './dispatcher-c export type RequestContext = { clientId: number - /** Monotonic transport generation; optional for in-process callers. */ - transportGeneration?: number isStale: () => boolean signal?: AbortSignal sessionIdentity?: RelayClientSessionIdentity diff --git a/src/relay/dispatcher-frame-codec.ts b/src/relay/dispatcher-frame-codec.ts index ffa67d2b12c..5a25fa1100f 100644 --- a/src/relay/dispatcher-frame-codec.ts +++ b/src/relay/dispatcher-frame-codec.ts @@ -85,8 +85,7 @@ export abstract class RelayDispatcherFrameCodec extends RelayDispatcherCapacityS frame: PreparedRelayFrame, lane: DispatcherWriterLane, onSettled: (result: SinkWriteSettlement) => void = () => {}, - controlOverflow: 'close-client' | 'reject' = 'close-client', - publicationAdmission?: () => boolean + controlOverflow: 'close-client' | 'reject' = 'close-client' ): boolean { if (this.disposed || client.closed) { return false @@ -96,12 +95,9 @@ export abstract class RelayDispatcherFrameCodec extends RelayDispatcherCapacityS return encodePreparedJsonRpcFrame(frame.payload, seq, client.highestReceivedSeq) } const admissionParams = frame.ptyDataAdmissionParams - const isStillAdmitted = - admissionParams || publicationAdmission - ? () => - (publicationAdmission?.() ?? true) && - (!admissionParams || this.admitsPtyDataPublication(client.id, admissionParams)) - : undefined + const isStillAdmitted = admissionParams + ? () => this.admitsPtyDataPublication(client.id, admissionParams) + : undefined return client.writer.enqueue( lane, encode, diff --git a/src/relay/dispatcher-notification-publication.ts b/src/relay/dispatcher-notification-publication.ts index ca09a503814..ab0851b4203 100644 --- a/src/relay/dispatcher-notification-publication.ts +++ b/src/relay/dispatcher-notification-publication.ts @@ -1,5 +1,4 @@ import type { SinkWriteSettlement } from './dispatcher-client-writer' -import { onceDispatcherWriterSettlement } from './dispatcher-writer-admission' import type { JsonRpcNotification } from './protocol' import { DROPPED_NOTIFICATION_LOG_KEY_LIMIT, @@ -9,16 +8,6 @@ import { import { RelayDispatcherPtyPublication } from './dispatcher-pty-publication' export abstract class RelayDispatcherNotificationPublication extends RelayDispatcherPtyPublication { - assertSettledProducerTransport(clientId: number, generation: number): void { - const client = this.clients.get(clientId) - if (this.disposed || !client || client.closed || client.generation !== generation) { - throw new Error('relay_producer_transport_unverifiable') - } - if (!client.writer.supportsWriteCallback) { - throw new Error('relay_producer_write_callback_required') - } - } - notify(method: string, params?: Record): void { if (this.disposed) { return @@ -61,24 +50,14 @@ export abstract class RelayDispatcherNotificationPublication extends RelayDispat params?: Record, // Why: a caller that recovers from rejection itself (the watcher emitter re-sends the batch in // chunks) would otherwise log "Dropped" for a frame it goes on to deliver in full. - options?: { - logDrop?: boolean - onSettled?: (result: SinkWriteSettlement) => void - settledTransportGeneration?: number - isStillAdmitted?: () => boolean - } + options?: { logDrop?: boolean } ): boolean { - const settle = onceDispatcherWriterSettlement(options?.onSettled ?? (() => {})) - const refuse = (message: string): false => { - settle({ ok: false, error: new Error(message) }) - return false - } if (this.disposed) { - return refuse('Relay dispatcher is disposed') + return false } const client = this.clients.get(clientId) if (!client || client.closed) { - return refuse('Relay client is not connected') + return false } const msg: JsonRpcNotification = { jsonrpc: '2.0', @@ -86,29 +65,17 @@ export abstract class RelayDispatcherNotificationPublication extends RelayDispat ...(params !== undefined ? { params } : {}) } if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) { - return refuse('Relay PTY publication is not admitted') + return false } - let frame: PreparedRelayFrame - try { - if (options?.settledTransportGeneration !== undefined) { - this.assertSettledProducerTransport(clientId, options.settledTransportGeneration) - } - frame = this.prepareFrame(msg) - if (options?.settledTransportGeneration !== undefined) { - this.assertSettledProducerTransport(clientId, options.settledTransportGeneration) - } - } catch (error) { - settle({ ok: false, error: error instanceof Error ? error : new Error(String(error)) }) - throw error - } - if (this.publishPreparedToClient(client, frame, 'ordinary', settle, options?.isStillAdmitted)) { + const frame = this.prepareFrame(msg) + if (this.publishPreparedToClient(client, frame, 'ordinary')) { return true } // Why: same diagnostics as notify() — a producer that drops here must not do so silently. if (options?.logDrop !== false) { this.logDroppedProducerNotification(client, method, frame.frameBytes) } - return refuse('Relay producer publication was not accepted') + return false } notifyClient(clientId: number, method: string, params?: Record): void { diff --git a/src/relay/dispatcher-producer-settlement.test.ts b/src/relay/dispatcher-producer-settlement.test.ts deleted file mode 100644 index 2c04f2cb6f7..00000000000 --- a/src/relay/dispatcher-producer-settlement.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' - -describe('producer notification settlement', () => { - const dispatchers: RelayDispatcher[] = [] - const setup = (highWaterMark?: number) => { - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settled) => { - writes.push(settled) - return true - }, - { supportsWriteCallback: true, writableHighWaterMark: () => highWaterMark ?? Infinity } - ) - dispatchers.push(dispatcher) - return { dispatcher, writes } - } - afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } - }) - - it('waits for sink settlement rather than enqueue acceptance and settles once', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - expect(dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled })).toBe(true) - expect(onSettled).not.toHaveBeenCalled() - writes[0]({ ok: true }) - writes[0]({ ok: false, error: new Error('late duplicate') }) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: true }) - }) - - it('retains sink failure and cannot turn disposal into successful publication', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled }) - const error = new Error('write failed') - writes[0]({ ok: false, error }) - dispatcher.dispose() - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error }) - }) - - it('fails outstanding publication on disposal even if a late write succeeds', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled }) - dispatcher.dispose() - writes[0]({ ok: true }) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - }) - - it('reports bounded producer refusal without closing the client or taking the control lane', () => { - const { dispatcher, writes } = setup(16384) - const onSettled = vi.fn() - const detached = vi.fn() - dispatcher.onClientDetached(detached) - expect( - dispatcher.publishProducerNotification( - 1, - 'tunnel.frame', - { data: 'x'.repeat(20000) }, - { - onSettled, - logDrop: false - } - ) - ).toBe(false) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - expect(writes).toHaveLength(0) - expect(detached).not.toHaveBeenCalled() - expect(dispatcher.publishProducerNotification(1, 'tunnel.frame', {})).toBe(true) - }) - - it.each(['missing', 'disposed', 'pty-admission'] as const)('settles %s refusal', (reason) => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - if (reason === 'disposed') { - dispatcher.dispose() - } - if (reason === 'pty-admission') { - dispatcher.registerPtyDataPublicationAdmission(() => false) - } - expect( - dispatcher.publishProducerNotification( - reason === 'missing' ? 999 : 1, - reason === 'pty-admission' ? 'pty.data' : 'tunnel.frame', - {}, - { onSettled } - ) - ).toBe(false) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - expect(writes).toHaveLength(0) - }) - - it('settles serialization failure while preserving the existing throw contract', () => { - const { dispatcher } = setup() - const onSettled = vi.fn() - const params: Record = {} - params.circular = params - expect(() => - dispatcher.publishProducerNotification(1, 'tunnel.frame', params, { onSettled }) - ).toThrow() - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - }) -}) diff --git a/src/relay/dispatcher-producer-transport.ts b/src/relay/dispatcher-producer-transport.ts index 5ef9717108f..089a69577d1 100644 --- a/src/relay/dispatcher-producer-transport.ts +++ b/src/relay/dispatcher-producer-transport.ts @@ -83,8 +83,7 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp client: RelayClient, frame: PreparedRelayFrame, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', - onSettled: (result: SinkWriteSettlement) => void = () => {}, - isStillAdmitted?: () => boolean + onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { const bytes = frame.frameBytes const fixedBlocked = @@ -97,7 +96,7 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp if (!leases) { return false } - return this.enqueueLeasedFrame(client, frame, lane, leases[0], onSettled, isStillAdmitted) + return this.enqueueLeasedFrame(client, frame, lane, leases[0], onSettled) } protected publishBulkWhenAvailable( @@ -149,21 +148,13 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp frame: PreparedRelayFrame, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', lease: LegacyPublicationLease, - onSettled: (result: SinkWriteSettlement) => void = () => {}, - isStillAdmitted?: () => boolean + onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { - const accepted = this.enqueuePreparedFrame( - client, - frame, - lane, - (result) => { - lease.release() - onSettled(result) - this.notifyLegacyCapacityIfLow() - }, - undefined, - isStillAdmitted - ) + const accepted = this.enqueuePreparedFrame(client, frame, lane, (result) => { + lease.release() + onSettled(result) + this.notifyLegacyCapacityIfLow() + }) if (!accepted) { lease.release() this.notifyLegacyCapacityIfLow() diff --git a/src/relay/dispatcher-rpc-routing.ts b/src/relay/dispatcher-rpc-routing.ts index 37689654a0d..a6e6c24190c 100644 --- a/src/relay/dispatcher-rpc-routing.ts +++ b/src/relay/dispatcher-rpc-routing.ts @@ -104,7 +104,6 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode } const context: RequestContext = { clientId: client.id, - transportGeneration: gen, isStale: () => client.generation !== gen || !this.clients.has(client.id) || abortController.signal.aborted, signal: abortController.signal, @@ -180,7 +179,6 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode const gen = client.generation handler(notif.params ?? {}, { clientId: client.id, - transportGeneration: gen, isStale: () => client.generation !== gen || !this.clients.has(client.id), sessionIdentity: client.sessionIdentity, onResponseSettled: () => { diff --git a/src/relay/dispatcher-work-drain.test.ts b/src/relay/dispatcher-work-drain.test.ts deleted file mode 100644 index 8724dbabec1..00000000000 --- a/src/relay/dispatcher-work-drain.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayDispatcher } from './dispatcher' -import type { RequestContext } from './dispatcher' -import { - encodeJsonRpcFrame, - type JsonRpcRequest, - type JsonRpcResponse, - type JsonRpcNotification -} from './protocol' - -const dispatchers: RelayDispatcher[] = [] - -afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } -}) -function fixture() { - const responses: Record[] = [] - const dispatcher = new RelayDispatcher((frame) => { - const length = frame.readUInt32BE(9) - responses.push(JSON.parse(frame.subarray(13, 13 + length).toString())) - return true - }) - dispatchers.push(dispatcher) - let sequence = 0 - const send = ( - message: - | Omit - | Omit - | Omit - ) => dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', ...message }, ++sequence, 0)) - return { dispatcher, responses, send } -} - -it('rejects new mutations over the wire while waiting for uncancellable admitted work', async () => { - const f = fixture() - const pending = Promise.withResolvers() - const mutation = vi.fn(() => pending.promise) - f.dispatcher.onRequest('fs.writeFile', mutation) - f.send({ id: 1, method: 'fs.writeFile' }) - const drained = vi.fn() - const drain = f.dispatcher.beginWorkDrain().then(drained) - f.send({ id: 2, method: 'fs.writeFile' }) - await vi.waitFor(() => - expect(f.responses).toContainEqual( - expect.objectContaining({ - id: 2, - error: expect.objectContaining({ message: 'relay_work_admission_closed' }) - }) - ) - ) - expect(mutation).toHaveBeenCalledOnce() - expect(drained).not.toHaveBeenCalled() - pending.resolve() - await drain -}) - -it('excludes only the initiating request from its own drain', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('fs.writeFile', () => work.promise) - const drained = vi.fn() - f.dispatcher.onRequest('test.drain', async (_params, context) => { - await f.dispatcher.beginWorkDrain(context) - drained() - return 'drained' - }) - f.send({ id: 1, method: 'fs.writeFile' }) - f.send({ id: 2, method: 'test.drain' }) - await new Promise((resolve) => setImmediate(resolve)) - expect(drained).not.toHaveBeenCalled() - work.resolve() - await vi.waitFor(() => - expect(f.responses).toContainEqual({ jsonrpc: '2.0', id: 2, result: 'drained' }) - ) -}) - -it('rejects copied and settled initiators without closing admission', async () => { - const f = fixture() - let actual: RequestContext | undefined - f.dispatcher.onRequest('test.capture', async (_params, context) => { - actual = context - await expect(f.dispatcher.beginWorkDrain({ ...context })).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - return 'captured' - }) - f.send({ id: 1, method: 'test.capture' }) - await vi.waitFor(() => expect(f.responses.some((response) => response.id === 1)).toBe(true)) - expect(() => f.dispatcher.assertActiveWorkContext(actual!)).toThrow( - 'relay_work_drain_context_not_active' - ) - const mutate = vi.fn(async () => 'still open') - f.dispatcher.onRequest('fs.writeFile', mutate) - f.send({ id: 2, method: 'fs.writeFile' }) - await vi.waitFor(() => expect(mutate).toHaveBeenCalledOnce()) -}) - -it('stamps the transport generation on request and notification contexts', async () => { - const f = fixture() - const seen: (number | undefined)[] = [] - f.dispatcher.onRequest('test.request', async (_params, context) => { - seen.push(context.transportGeneration) - return 'ok' - }) - f.dispatcher.onNotification('test.notify', (_params, context) => { - seen.push(context.transportGeneration) - }) - f.send({ id: 1, method: 'test.request' }) - f.send({ method: 'test.notify' }) - await vi.waitFor(() => expect(seen).toHaveLength(2)) - expect(seen[0]).toEqual(expect.any(Number)) - expect(seen[1]).toBe(seen[0]) -}) - -it('preserves ACK notifications and cancellation during drain without admitting new notifications', async () => { - const f = fixture() - const pending = Promise.withResolvers() - let signal: AbortSignal | undefined - f.dispatcher.onRequest('git.diff', (_params, context) => { - signal = context.signal - return pending.promise - }) - const write = vi.fn() - const ack = vi.fn(() => pending.resolve()) - f.dispatcher.onNotification('pty.write', write) - f.dispatcher.onNotification('git.responseAck', ack) - f.send({ id: 1, method: 'git.diff' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ method: 'pty.write' }) - f.send({ method: 'rpc.cancel', params: { id: 1 } }) - expect(signal?.aborted).toBe(true) - f.send({ method: 'git.responseAck' }) - await drain - expect(write).not.toHaveBeenCalled() - expect(ack).toHaveBeenCalledOnce() -}) - -it('lets an admitted operation receive its reverse RPC response while draining', async () => { - const f = fixture() - f.dispatcher.onRequest('orca.cli', () => f.dispatcher.requestPrimary('client.operation')) - f.send({ id: 10, method: 'orca.cli' }) - const drain = f.dispatcher.beginWorkDrain() - const request = f.responses.find((frame) => frame.method === 'client.operation')! - expect(request).toBeDefined() - f.send({ id: Number(request.id), result: 'done' }) - await drain - await vi.waitFor(() => - expect(f.responses).toContainEqual({ jsonrpc: '2.0', id: 10, result: 'done' }) - ) -}) - -it('keeps skill upload cancellation executable while admitted installation work drains', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('skills.install', () => work.promise) - const cancel = vi.fn(async () => work.resolve()) - f.dispatcher.onRequest('skills.cancelUpload', cancel) - f.send({ id: 1, method: 'skills.install' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ id: 2, method: 'skills.cancelUpload' }) - await drain - expect(cancel).toHaveBeenCalledOnce() -}) - -it('keeps PTY source delivery cancellation executable while admitted work drains', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('pty.attach', () => work.promise) - const cancel = vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) - f.dispatcher.onRequest('pty.cancelDelivery', cancel) - f.send({ id: 1, method: 'pty.attach' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ id: 2, method: 'pty.cancelDelivery' }) - await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()) - work.resolve() - await drain - await vi.waitFor(() => - expect(f.responses).toContainEqual( - expect.objectContaining({ id: 2, result: expect.objectContaining({ canceled: true }) }) - ) - ) -}) diff --git a/src/relay/fs-file-stream-shutdown.test.ts b/src/relay/fs-file-stream-shutdown.test.ts deleted file mode 100644 index c26fb73b2c4..00000000000 --- a/src/relay/fs-file-stream-shutdown.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import { readRelayFileStreamMetadata } from './fs-handler-file-read' -import { RelayStreamRegistry } from './fs-stream-registry' -import { STREAM_CHUNK_SIZE } from './protocol' - -let directory: string -let filePath: string -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-file-stream-shutdown-')) - filePath = join(directory, 'sample.png') - await writeFile(filePath, Buffer.alloc(12, 42)) -}) -afterEach(async () => { - vi.restoreAllMocks() - await rm(directory, { recursive: true, force: true }) -}) - -function fixture() { - const registry = new RelayStreamRegistry() - const bulk = vi.fn(async () => {}) - const terminal = vi.fn((_id, _method, _params, settled) => { - settled({ ok: true }) - return true - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the file stream reads only these two dispatcher methods. - const dispatcher = { notifyBulk: bulk, tryNotifyClient: terminal } as unknown as RelayDispatcher - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream reads only clientId and isStale from its context. - const context = { clientId: 1, isStale: () => false } as RequestContext - const start = (paceWithAcks = false) => - readRelayFileStreamMetadata(filePath, dispatcher, registry, context, { - clientId: 1, - paceWithAcks - }) - return { registry, bulk, terminal, start } -} - -it('waits for a scheduled pump and permanently fences later metadata requests', async () => { - const f = fixture() - const scheduled: (() => void)[] = [] - // A real, already-cleared handle satisfies the return type without scheduling anything. - const handle = setImmediate(() => {}) - clearImmediate(handle) - const schedule = vi.spyOn(globalThis, 'setImmediate').mockImplementation((callback) => { - scheduled.push(callback) - return handle - }) - await f.start() - schedule.mockRestore() - const finished = vi.fn() - const drain = f.registry.disposeAll().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(f.start()).rejects.toThrow('relay_file_stream_shutdown_fenced') - scheduled.forEach((run) => run()) - await drain - expect(f.bulk).not.toHaveBeenCalled() - expect(f.terminal).not.toHaveBeenCalled() -}) - -it.each(['resolve', 'reject'] as const)( - 'waits for an in-flight final chunk to %s without a terminal frame', - async (outcome) => { - const f = fixture() - const pending = Promise.withResolvers() - f.bulk.mockReturnValue(pending.promise) - await f.start() - await vi.waitFor(() => expect(f.bulk).toHaveBeenCalledOnce()) - const finished = vi.fn() - const drain = f.registry.disposeAll().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - if (outcome === 'resolve') { - pending.resolve() - } else { - pending.reject(new Error('transport closed')) - } - await drain - expect(f.terminal).not.toHaveBeenCalled() - } -) - -it('wakes an ACK-parked producer during shutdown', async () => { - const f = fixture() - await writeFile(filePath, Buffer.alloc(STREAM_CHUNK_SIZE * 6, 42)) - await f.start(true) - await vi.waitFor(() => expect(f.bulk).toHaveBeenCalledTimes(4)) - await f.registry.disposeAll() - expect(f.bulk).toHaveBeenCalledTimes(4) - expect(f.terminal).not.toHaveBeenCalled() -}) - -it('retains a binary-probe handle when its close fails and retries shutdown cleanup', async () => { - const f = fixture() - filePath = join(directory, 'sample.txt') - await writeFile(filePath, 'text content') - const failure = new Error('probe close failed') - const release = f.registry.releaseUnregisteredHandle.bind(f.registry) - vi.spyOn(f.registry, 'releaseUnregisteredHandle').mockImplementationOnce((handle) => { - vi.spyOn(handle, 'close').mockRejectedValueOnce(failure) - return release(handle) - }) - try { - await expect(f.start()).rejects.toBe(failure) - expect(f.registry.size()).toBe(1) - await f.registry.disposeAll() - expect(f.registry.size()).toBe(0) - } finally { - await f.registry.disposeAll() - } -}) diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index c07c897fc8b..e8b7993fbcd 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -2,9 +2,8 @@ import { open, readFile, stat } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { extname } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol' -import type { RelayStreamRegistry } from './fs-stream-registry' -import { reserveTerminalFrameSlot } from './fs-stream-terminal-frame-slots' +import { MAX_CONCURRENT_STREAMS, STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol' +import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry' import { BINARY_PROBE_BYTES, IMAGE_MIME_TYPES, @@ -78,21 +77,6 @@ export async function readRelayFileStreamMetadata( registry: RelayStreamRegistry, context: RequestContext, pumpOptions?: StreamPumpOptions -): Promise { - const finish = registry.beginOperation() - try { - return await prepareRelayFileStream(filePath, dispatcher, registry, context, pumpOptions) - } finally { - finish() - } -} - -async function prepareRelayFileStream( - filePath: string, - dispatcher: RelayDispatcher, - registry: RelayStreamRegistry, - context: RequestContext, - pumpOptions?: StreamPumpOptions ): Promise { const stats = await stat(filePath) const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] @@ -115,10 +99,7 @@ async function prepareRelayFileStream( // Why: unlike the legacy single-shot path, streaming does not read the full // buffer before classifying content. Probe every unknown file so small binary // files do not get decoded as UTF-8 text over SSH. - if ( - !mimeType && - (await isBinaryFilePrefix(filePath, (handle) => registry.releaseUnregisteredHandle(handle))) - ) { + if (!mimeType && (await isBinaryFilePrefix(filePath))) { return { totalSize: 0, isBinary: true, empty: true } } @@ -131,13 +112,8 @@ async function prepareRelayFileStream( handle = await open(filePath, 'r') streamId = registry.register(handle) } catch (err) { - try { - if (handle) { - await registry.releaseUnregisteredHandle(handle) - } - } finally { - releaseTerminalFrameSlot() - } + await handle?.close() + releaseTerminalFrameSlot() throw err } @@ -147,7 +123,6 @@ async function prepareRelayFileStream( // setImmediate kicks the pump off the metadata-response task so the client // sees the response before the first chunk frame. const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } - const finishPump = registry.beginOperation() setImmediate(() => { void pumpChunks( streamId, @@ -158,10 +133,6 @@ async function prepareRelayFileStream( resolvedPumpOptions, releaseTerminalFrameSlot ) - .catch((error: unknown) => { - process.stderr.write(`[relay] stream cleanup failed id=${streamId}: ${String(error)}\n`) - }) - .finally(finishPump) }) return { @@ -175,6 +146,48 @@ async function prepareRelayFileStream( } } +/** + * Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not + * drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's + * registry slot is gone the moment its last chunk is read, so on a socket that is not + * draining, back-to-back reads could stack one undelivered terminal frame each until + * that budget blew. Holding the slot until the frame settles keeps the number of queued + * terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the + * overflow now costs one refused read (TooManyStreams, which clients already handle) + * instead of the whole connection. + * + * Counted per client, because the control queue this protects is per client: one peer whose + * socket stopped draining must not refuse reads for every other peer on the same relay. + */ +const pendingTerminalFramesByClient = new WeakMap>() + +function reserveTerminalFrameSlot(registry: RelayStreamRegistry, clientId: number): () => void { + let byClient = pendingTerminalFramesByClient.get(registry) + if (!byClient) { + byClient = new Map() + pendingTerminalFramesByClient.set(registry, byClient) + } + const pending = byClient.get(clientId) ?? 0 + if (pending >= MAX_CONCURRENT_STREAMS) { + throw new TooManyStreamsError() + } + byClient.set(clientId, pending + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (byClient.get(clientId) ?? 1) - 1 + // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. + if (remaining <= 0) { + byClient.delete(clientId) + return + } + byClient.set(clientId, remaining) + } +} + async function pumpChunks( streamId: number, totalSize: number, @@ -289,11 +302,6 @@ async function pumpChunks( releaseTerminalFrameSlot ) } - if (registry.isAborted(streamId)) { - endReason = 'aborted' - } else if (context.isStale()) { - endReason = 'stale' - } if (endReason === 'end') { publishTerminal('fs.streamEnd', { streamId }) process.stderr.write(`[relay] stream end id=${streamId}\n`) @@ -317,12 +325,9 @@ async function pumpChunks( } finally { // Why: the fd goes back first — a terminal frame that can never be delivered must not // strand it. Cancelled/stale streams publish nothing, so nothing else frees their slot. - try { - await registry.release(streamId) - } finally { - if (!slotReleaseDeferred) { - releaseTerminalFrameSlot() - } + await registry.release(streamId) + if (!slotReleaseDeferred) { + releaseTerminalFrameSlot() } } } diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index 9cbfd687376..ba6cfbf1877 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -492,29 +492,6 @@ describe('FsHandler readFileStream', () => { await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 2) }) - it('holds file stream shutdown until an undelivered terminal frame settles', async () => { - const filePath = path.join(tmpDir, 'shutdown-terminal.png') - writeFileSync(filePath, Buffer.alloc(STREAM_CHUNK_SIZE, 0x42)) - dispatcher.holdControlSettlements() - const context = { clientId: 4, isStale: () => false } - await dispatcher.callRequest('fs.readFileStream', { filePath }, context) - await waitFor(() => collectStream(dispatcher).end !== null) - - let drained = false - const drain = handler.disposeFileStreams().then(() => { - drained = true - }) - await flush(10) - expect(drained).toBe(false) - await expect( - dispatcher.callRequest('fs.readFileStream', { filePath }, context) - ).rejects.toThrow('relay_file_stream_shutdown_fenced') - - dispatcher.settleHeldControlFrames() - await drain - expect(drained).toBe(true) - }) - it('rejects the 17th concurrent stream with TooManyStreams', async () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 0696b48046d..a3b2234ced0 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -64,18 +64,14 @@ export function isBinaryBuffer(buffer: Buffer): boolean { return false } -export async function isBinaryFilePrefix( - filePath: string, - releaseHandle: (handle: Awaited>) => Promise = (handle) => - handle.close() -): Promise { +export async function isBinaryFilePrefix(filePath: string): Promise { const handle = await open(filePath, 'r') try { const probe = Buffer.alloc(BINARY_PROBE_BYTES) const { bytesRead } = await handle.read(probe, 0, probe.length, 0) return isBinaryBuffer(probe.subarray(0, bytesRead)) } finally { - await releaseHandle(handle) + await handle.close() } } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 87ef3444d83..36765721ef3 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -267,16 +267,6 @@ export class FsHandler { dispose(): void { this.watchRegistry.dispose() - void this.disposeFileStreams().catch((error: unknown) => { - process.stderr.write(`[relay] file stream shutdown failed: ${String(error)}\n`) - }) - } - - disposeFileStreams(): Promise { - return this.streamRegistry.disposeAll() - } - - disposeWatchers(): Promise { - return this.watchRegistry.disposeAndWait() + void this.streamRegistry.disposeAll() } } diff --git a/src/relay/fs-stream-registry-shutdown.test.ts b/src/relay/fs-stream-registry-shutdown.test.ts deleted file mode 100644 index c79dfb860d4..00000000000 --- a/src/relay/fs-stream-registry-shutdown.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { FileHandle } from 'node:fs/promises' -import { describe, expect, it, vi } from 'vitest' -import { RelayStreamRegistry } from './fs-stream-registry' - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -async function flushTasks(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -function handle(close: ReturnType): FileHandle { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry only ever calls close() on a handle. - return { close } as unknown as FileHandle -} - -describe('RelayStreamRegistry shutdown', () => { - it('retains failed late pre-registration cleanup after the initial disposal snapshot', async () => { - const registry = new RelayStreamRegistry() - const finishMetadata = registry.beginOperation() - const failure = new Error('late handle close failed') - const close = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(undefined) - const result = registry.disposeAll().catch((error: unknown) => error) - await flushTasks() - const openedHandle = handle(close) - expect(() => registry.register(openedHandle)).toThrow('relay_file_stream_shutdown_fenced') - await expect(registry.releaseUnregisteredHandle(openedHandle)).rejects.toBe(failure) - finishMetadata() - expect(await result).toMatchObject({ - message: 'relay_file_stream_shutdown_incomplete', - errors: [failure] - }) - expect(registry.size()).toBe(1) - await registry.disposeAll() - expect(registry.size()).toBe(0) - expect(close).toHaveBeenCalledTimes(2) - }) - - it('waits for a late pre-registration close owned by an admitted metadata operation', async () => { - const registry = new RelayStreamRegistry() - const finishMetadata = registry.beginOperation() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const finished = vi.fn() - const drain = registry.disposeAll().then(finished) - await flushTasks() - const cleanup = registry.releaseUnregisteredHandle(handle(close)).finally(finishMetadata) - await flushTasks() - expect(finished).not.toHaveBeenCalled() - pending.resolve() - await Promise.all([cleanup, drain]) - expect(registry.size()).toBe(0) - }) - - it('joins a release already waiting for the file handle to close', async () => { - const registry = new RelayStreamRegistry() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const id = registry.register(handle(close)) - const release = registry.release(id) - let disposed = false - const dispose = registry.disposeAll().then(() => { - disposed = true - }) - - try { - await flushTasks() - expect(disposed).toBe(false) - expect(close).toHaveBeenCalledTimes(1) - expect(registry.isAborted(id)).toBe(true) - } finally { - pending.resolve() - await Promise.all([release, dispose]) - } - expect(disposed).toBe(true) - expect(registry.size()).toBe(0) - }) - - it('coalesces concurrent release calls until the single close settles', async () => { - const registry = new RelayStreamRegistry() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const id = registry.register(handle(close)) - const first = registry.release(id) - let secondSettled = false - const second = registry.release(id).then(() => { - secondSettled = true - }) - - try { - await flushTasks() - expect(close).toHaveBeenCalledTimes(1) - expect(secondSettled).toBe(false) - } finally { - pending.resolve() - await Promise.all([first, second]) - } - expect(registry.size()).toBe(0) - }) - - it('retains a failed close for a later disposal retry', async () => { - const registry = new RelayStreamRegistry() - const failure = Object.assign(new Error('close failed'), { code: 'EIO' }) - const close = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) - const id = registry.register(handle(close)) - - await expect(registry.release(id)).rejects.toBe(failure) - expect(registry.size()).toBe(1) - expect(registry.isAborted(id)).toBe(true) - await registry.disposeAll() - - expect(close).toHaveBeenCalledTimes(2) - expect(registry.size()).toBe(0) - }) - - it('waits for every close before rejecting disposal and retries only failed handles', async () => { - const registry = new RelayStreamRegistry() - const failure = Object.assign(new Error('close failed'), { code: 'EIO' }) - const failedClose = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) - const pending = deferred() - const pendingClose = vi.fn(() => pending.promise) - registry.register(handle(failedClose)) - registry.register(handle(pendingClose)) - let settled = false - const result = registry.disposeAll().then( - () => { - settled = true - return undefined - }, - (error: unknown) => { - settled = true - return error - } - ) - - try { - await flushTasks() - expect(settled).toBe(false) - expect(failedClose).toHaveBeenCalledTimes(1) - expect(pendingClose).toHaveBeenCalledTimes(1) - } finally { - pending.resolve() - } - const error = await result - expect(error).toBeInstanceOf(AggregateError) - expect(error).toMatchObject({ errors: [failure] }) - expect(registry.size()).toBe(1) - - await registry.disposeAll() - expect(failedClose).toHaveBeenCalledTimes(2) - expect(pendingClose).toHaveBeenCalledTimes(1) - expect(registry.size()).toBe(0) - }) - - it('tolerates EBADF as an already-closed handle', async () => { - const registry = new RelayStreamRegistry() - const close = vi.fn().mockRejectedValue(Object.assign(new Error('closed'), { code: 'EBADF' })) - const id = registry.register(handle(close)) - - await registry.release(id) - await registry.disposeAll() - - expect(close).toHaveBeenCalledTimes(1) - expect(registry.size()).toBe(0) - }) -}) diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index 23a8aab662a..2bca76c9495 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -21,44 +21,11 @@ export class TooManyStreamsError extends Error { export class RelayStreamRegistry { private streams = new Map() private nextId = 1 - private disposed = false - private closing = new Map>() - private closeFailures = new Map() - private operations = new Set>() - - /** Tracks work that may still open or release a handle; disposeAll waits for it. */ - beginOperation(): () => void { - if (this.disposed) { - throw new Error('relay_file_stream_shutdown_fenced') - } - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let resolve!: () => void - const pending = new Promise((settle) => { - resolve = settle - }) - this.operations.add(pending) - return () => { - this.operations.delete(pending) - resolve() - } - } register(handle: FileHandle): number { - if (this.disposed) { - throw new Error('relay_file_stream_shutdown_fenced') - } if (this.streams.size >= MAX_CONCURRENT_STREAMS) { throw new TooManyStreamsError() } - return this.retainHandle(handle) - } - - /** Closes a handle opened outside a stream; a failed close stays retained for disposal retry. */ - releaseUnregisteredHandle(handle: FileHandle): Promise { - return this.release(this.retainHandle(handle)) - } - - private retainHandle(handle: FileHandle): number { const streamId = this.nextId++ this.streams.set(streamId, { handle, @@ -138,43 +105,26 @@ export class RelayStreamRegistry { } } - /** Concurrent callers share one close; only EBADF counts as already closed. */ - release(streamId: number): Promise { - const pending = this.closing.get(streamId) - if (pending) { - return pending - } + async release(streamId: number): Promise { const entry = this.streams.get(streamId) if (!entry) { - return Promise.resolve() + return + } + this.wakeAckWaiters(entry) + this.streams.delete(streamId) + try { + await entry.handle.close() + } catch { + // release runs from multiple exit paths (pump, cancel, dispose); a + // second close throws EBADF — swallow it. } - this.abort(streamId) - const close = Promise.resolve() - .then(() => entry.handle.close()) - .catch((error: unknown) => { - if (!isErrorWithCode(error, 'EBADF')) { - this.closeFailures.set(streamId, error) - throw error - } - }) - .then(() => { - this.streams.delete(streamId) - this.closeFailures.delete(streamId) - }) - .finally(() => { - this.closing.delete(streamId) - }) - this.closing.set(streamId, close) - return close } size(): number { return this.streams.size } - /** Permanently fences new streams; rejects while any handle is still unclosed so a retry can finish. */ async disposeAll(): Promise { - this.disposed = true // Why: flag every stream as aborted so any in-flight pump exits its loop // cleanly on the next iteration boundary instead of seeing EBADF when // release closes the handle out from under an in-flight read. @@ -182,20 +132,6 @@ export class RelayStreamRegistry { this.abort(id) } const ids = Array.from(this.streams.keys()) - const results = await Promise.allSettled(ids.map((id) => this.release(id))) - await Promise.all(this.operations) - const failures = results.filter((result) => result.status === 'rejected') - if (failures.length > 0 || this.streams.size > 0) { - throw new AggregateError( - [ - ...new Set([...failures.map((failure) => failure.reason), ...this.closeFailures.values()]) - ], - 'relay_file_stream_shutdown_incomplete' - ) - } + await Promise.all(ids.map((id) => this.release(id))) } } - -function isErrorWithCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} diff --git a/src/relay/fs-stream-terminal-frame-slots.ts b/src/relay/fs-stream-terminal-frame-slots.ts deleted file mode 100644 index 5ede13b5d79..00000000000 --- a/src/relay/fs-stream-terminal-frame-slots.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MAX_CONCURRENT_STREAMS } from './protocol' -import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry' - -/** - * Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not - * drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's - * registry slot is gone the moment its last chunk is read, so on a socket that is not - * draining, back-to-back reads could stack one undelivered terminal frame each until - * that budget blew. Holding the slot until the frame settles keeps the number of queued - * terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the - * overflow now costs one refused read (TooManyStreams, which clients already handle) - * instead of the whole connection. - * - * Counted per client, because the control queue this protects is per client: one peer whose - * socket stopped draining must not refuse reads for every other peer on the same relay. - * Counted independently of released file descriptors, and each slot is a registry operation - * so shutdown waits for undelivered terminal frames too. - */ -const pendingTerminalFramesByClient = new WeakMap>() - -export function reserveTerminalFrameSlot( - registry: RelayStreamRegistry, - clientId: number -): () => void { - let byClient = pendingTerminalFramesByClient.get(registry) - if (!byClient) { - byClient = new Map() - pendingTerminalFramesByClient.set(registry, byClient) - } - const pending = byClient.get(clientId) ?? 0 - if (pending >= MAX_CONCURRENT_STREAMS) { - throw new TooManyStreamsError() - } - const finish = registry.beginOperation() - byClient.set(clientId, pending + 1) - let released = false - return () => { - if (released) { - return - } - released = true - finish() - const remaining = (byClient.get(clientId) ?? 1) - 1 - // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. - if (remaining <= 0) { - byClient.delete(clientId) - return - } - byClient.set(clientId, remaining) - } -} diff --git a/src/relay/git-response-stream-shutdown.test.ts b/src/relay/git-response-stream-shutdown.test.ts deleted file mode 100644 index 4ae10c539c9..00000000000 --- a/src/relay/git-response-stream-shutdown.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' - -const context: RequestContext = { clientId: 7, isStale: () => false } - -async function flushPump(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -describe('GitResponseStreamRegistry shutdown', () => { - const registries: GitResponseStreamRegistry[] = [] - - function fixture(): { - registry: GitResponseStreamRegistry - dispatcher: RelayDispatcher - notifyBulk: ReturnType - } { - const registry = new GitResponseStreamRegistry() - registries.push(registry) - const notifyBulk = vi.fn().mockResolvedValue(undefined) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the response pump only calls notifyBulk (producerDataBudget is optional). - return { registry, notifyBulk, dispatcher: { notifyBulk } as unknown as RelayDispatcher } - } - - afterEach(async () => { - await Promise.all(registries.splice(0).map((registry) => registry.disposeAllAndWait())) - }) - - it('drains a scheduled producer without publishing any frames', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - registry.startStream(Buffer.from('payload'), dispatcher, context) - - await registry.disposeAllAndWait() - await flushPump() - - expect(notifyBulk).not.toHaveBeenCalled() - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - await registry.disposeAllAndWait() - }) - - it('waits for an in-flight final chunk and never publishes responseEnd after abort', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const write = deferred() - notifyBulk.mockImplementationOnce(() => write.promise) - registry.startStream(Buffer.from('one chunk'), dispatcher, context) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(1) - - let drained = false - const firstDrain = registry.disposeAllAndWait().then(() => { - drained = true - }) - const secondDrain = registry.disposeAllAndWait() - try { - await flushPump() - expect(drained).toBe(false) - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - } finally { - write.resolve() - await Promise.all([firstDrain, secondDrain]) - } - - expect(drained).toBe(true) - expect(notifyBulk).toHaveBeenCalledTimes(1) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - }) - - it('wakes producers parked on client ACKs without waiting for the stall timer', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - registry.startStream( - Buffer.alloc(GIT_RESPONSE_CHUNK_SIZE * (STREAM_ACK_WINDOW_CHUNKS + 1)), - dispatcher, - context - ) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - await flushPump() - - expect(drained).toBe(true) - await drain - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - expect(notifyBulk.mock.calls.every(([method]) => method === 'git.responseChunk')).toBe(true) - }) - - it('waits for an already-published responseEnd to settle', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const endWrite = deferred() - notifyBulk.mockResolvedValueOnce(undefined).mockImplementationOnce(() => endWrite.promise) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - expect(notifyBulk.mock.calls.map(([method]) => method)).toEqual([ - 'git.responseChunk', - 'git.responseEnd' - ]) - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - try { - await flushPump() - expect(drained).toBe(false) - } finally { - endWrite.resolve() - await drain - } - expect(drained).toBe(true) - expect(notifyBulk).toHaveBeenCalledTimes(2) - }) - - it('suppresses error publication when an aborted in-flight write rejects', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - let rejectWrite!: (error: Error) => void - notifyBulk.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectWrite = reject - }) - ) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - - const drain = registry.disposeAllAndWait() - rejectWrite(new Error('channel closed during shutdown')) - await drain - - expect(notifyBulk).toHaveBeenCalledTimes(1) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - }) - - it('synchronous disposal also permanently fences admission and retains the pending drain', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const write = deferred() - notifyBulk.mockImplementationOnce(() => write.promise) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - registry.disposeAll() - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - try { - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - await flushPump() - expect(drained).toBe(false) - } finally { - write.resolve() - await drain - } - - expect(notifyBulk).toHaveBeenCalledTimes(1) - await registry.disposeAllAndWait() - }) -}) diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts index 922c8637d3a..c9d8d2ce290 100644 --- a/src/relay/git-response-stream.ts +++ b/src/relay/git-response-stream.ts @@ -44,13 +44,8 @@ function encodeChunks(payload: Buffer, chunkBytes = GIT_RESPONSE_CHUNK_SIZE): st export class GitResponseStreamRegistry { private streams = new Map() private nextId = 1 - private disposed = false - private readonly pendingPumps = new Set>() private register(ownerClientId: number): number { - if (this.disposed) { - throw new Error('relay_response_stream_shutdown_fenced') - } const streamId = this.nextId++ this.streams.set(streamId, { ownerClientId, @@ -146,17 +141,8 @@ export class GitResponseStreamRegistry { const chunks = encodeChunks(payload, Math.min(GIT_RESPONSE_CHUNK_SIZE, sinkChunkBytes)) // Why: kick the pump off the response task so the client sees the sentinel // (and can subscribe/reassemble) before the first chunk frame arrives. - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let finish!: () => void - const completion = new Promise((resolve) => { - finish = () => { - this.pendingPumps.delete(completion) - resolve() - } - }) - this.pendingPumps.add(completion) setImmediate(() => { - void this.pump(streamId, chunks, dispatcher, context).then(finish, finish) + void this.pump(streamId, chunks, dispatcher, context) }) return { __orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length } @@ -213,8 +199,7 @@ export class GitResponseStreamRegistry { } ) } - // Why: disposal may abort while the final chunk write is in flight. - if (endReason === 'end' && !entry.aborted && !context.isStale()) { + if (endReason === 'end') { await dispatcher.notifyBulk('git.responseEnd', { streamId }, { clientId }) } } catch (err) { @@ -238,20 +223,13 @@ export class GitResponseStreamRegistry { } } - /** Permanently fences new streams and aborts every pump; use disposeAllAndWait to await them. */ disposeAll(): void { - this.disposed = true for (const entry of this.streams.values()) { entry.aborted = true this.wake(entry) } this.streams.clear() } - - async disposeAllAndWait(): Promise { - this.disposeAll() - await Promise.all(this.pendingPumps) - } } /** diff --git a/src/relay/relay-agent-process-lifetime.ts b/src/relay/relay-agent-process-lifetime.ts deleted file mode 100644 index 9e290d3e952..00000000000 --- a/src/relay/relay-agent-process-lifetime.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { terminateRelaySubprocessTree } from './subprocess-tree-termination' - -type Child = Parameters[0] - -// Why 10s: the same physical-exit deadline the relay's watcher children get after a kill. -export const RELAY_AGENT_CLOSE_DEADLINE_MS = 10_000 - -/** Request timeout/cancellation is not evidence that its host child has closed. */ -export class RelayAgentProcessLifetime { - private fenced = false - private readonly children = new Map>() - // Why: a retry re-checks close instead of re-killing a tree whose pid may already be reused. - private readonly signalled = new WeakSet() - private disposal: Promise | null = null - - constructor(private readonly closeDeadlineMs = RELAY_AGENT_CLOSE_DEADLINE_MS) {} - - assertAdmission(): void { - if (this.fenced) { - throw new Error('relay_agent_execution_shutdown_fenced') - } - } - - track(child: Child): void { - this.assertAdmission() - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let resolveClosed!: () => void - const closed = new Promise((resolve) => { - resolveClosed = resolve - }) - this.children.set(child, closed) - // A late error after request timeout must not remove physical-close tracking or crash the host. - const onError = () => {} - child.on('error', onError) - child.once('close', () => { - child.off('error', onError) - this.children.delete(child) - resolveClosed() - }) - } - - dispose(): Promise { - this.fenced = true - if (this.disposal) { - return this.disposal - } - const pending = [...this.children.entries()] - for (const [child] of pending) { - if (!this.signalled.has(child)) { - this.signalled.add(child) - terminateRelaySubprocessTree(child) - } - } - const disposal = this.waitForClose(pending.map(([, closed]) => closed)).finally(() => { - this.disposal = null - }) - this.disposal = disposal - return disposal - } - - private waitForClose(closes: Promise[]): Promise { - if (closes.length === 0) { - return Promise.resolve() - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('relay_agent_execution_shutdown_incomplete')) - }, this.closeDeadlineMs) - timer.unref?.() - void Promise.all(closes).then(() => { - clearTimeout(timer) - resolve() - }) - }) - } -} diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts index f9018000352..649fde3427a 100644 --- a/src/relay/relay-daemon.ts +++ b/src/relay/relay-daemon.ts @@ -1,8 +1,4 @@ -import { join } from 'node:path' import { installRelayLogRotation } from './rotating-log-writer' -import { registerRelayOwnerReset } from './relay-owner-reset-registration' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import { readLaunchVersion } from './relay-handshake' import type { RelayLaunchOptions } from './relay-launch-options' import { RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, RELAY_IDLE_GRACE_MS } from './relay-launch-options' @@ -94,24 +90,6 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise } } ) - // Beside the relay endpoint so a prepared reset survives a daemon restart. - const resetJournal = new RelayOwnerResetPreparationJournal( - join( - options.endpointDir ?? endpointDirForRelaySocket(options.sockPath), - 'owner-reset-preparations' - ), - options.sockPath, - launchVersion - ) - const readOwnerResetStatus = registerRelayOwnerReset(primaryChannel.dispatcher, { - owners: runtime.ptyConsumerSessionAdapter, - lifecycle, - persistPrepared: (request, principal, authenticationKind, assertAuthority) => - resetJournal.persist(request, principal, authenticationKind, assertAuthority), - describePreparation: (principal, authenticationKind) => - resetJournal.describe(principal, authenticationKind), - socket: socketOwnership - }) const startedAt = Date.now() registerRelayStatus( primaryChannel, @@ -120,8 +98,7 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise socketOwnership, lifecycle, options, - startedAt, - readOwnerResetStatus + startedAt ) try { @@ -170,37 +147,32 @@ function registerRelayStatus( socketOwnership: RelaySocketOwnership, lifecycle: RelayGraceLifecycle, options: RelayLaunchOptions, - startedAt: number, - readOwnerResetStatus: ReturnType + startedAt: number ): void { - primaryChannel.dispatcher.onRequest('relay.status', async (_params, context) => { - const resetStatus = readOwnerResetStatus(context) - return { - ...resetStatus, - capabilities: [...SKILL_RELAY_CAPABILITIES, ...resetStatus.capabilities], - pid: process.pid, - uptimeMs: Date.now() - startedAt, - detached: options.detached, - stdoutAlive: primaryChannel.isAlive, - memory: process.memoryUsage(), - ptys: { active: runtime.ptyHandler.activePtyCount }, - ptySourceCredit: { - enabled: true, - session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), - publication: runtime.ptySourcePublication.getDebugSnapshot() - }, - socket: { - path: options.sockPath, - owned: socketOwnership.owned, - listening: socketOwnership.server?.listening ?? false, - clients: reconnectListener.clientCount, - acceptedConnections: reconnectListener.acceptedConnections - }, - grace: { - active: runtime.ptyHandler.graceTimerActive, - deadlineAt: lifecycle.deadlineAt, - reason: lifecycle.reason - } + primaryChannel.dispatcher.onRequest('relay.status', async () => ({ + capabilities: SKILL_RELAY_CAPABILITIES, + pid: process.pid, + uptimeMs: Date.now() - startedAt, + detached: options.detached, + stdoutAlive: primaryChannel.isAlive, + memory: process.memoryUsage(), + ptys: { active: runtime.ptyHandler.activePtyCount }, + ptySourceCredit: { + enabled: true, + session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), + publication: runtime.ptySourcePublication.getDebugSnapshot() + }, + socket: { + path: options.sockPath, + owned: socketOwnership.owned, + listening: socketOwnership.server?.listening ?? false, + clients: reconnectListener.clientCount, + acceptedConnections: reconnectListener.acceptedConnections + }, + grace: { + active: runtime.ptyHandler.graceTimerActive, + deadlineAt: lifecycle.deadlineAt, + reason: lifecycle.reason } - }) + })) } diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index e0e538b0736..14f5d5b2ee8 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -1,5 +1,6 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' +import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions @@ -9,7 +10,7 @@ import { type RelayWatcherProcessPool } from './relay-watcher-process-pool' import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' -import { awaitRelayWatcherSetupForClient, startInitialRelayWatch } from './relay-watcher-setup-wait' +import { awaitRelayWatcherSetup, shouldRetryInitialRelayWatch } from './relay-watcher-setup-wait' import { RelayWatcherTeardownTracker, type RelayWatcherTeardownState @@ -26,7 +27,6 @@ import { releaseStaleRelayWatches } from './relay-watcher-stale-client-release' import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' import { joinRelayWatcherPendingSetup } from './relay-watcher-pending-setup-join' import { createRelayWatcherState } from './relay-watcher-state' -import { closeRelayWatchesAndWait, disposeRelayWatchesAndWait } from './relay-watcher-shutdown' const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) @@ -40,7 +40,6 @@ export class RelayFilesystemWatchRegistry { ) private readonly teardownTracker: RelayWatcherTeardownTracker private readonly removalFence: RelayWatcherRemovalFence - private disposed = false constructor( private readonly dispatcher: RelayDispatcher, @@ -59,9 +58,6 @@ export class RelayFilesystemWatchRegistry { } watch(rootPath: string, context?: RequestContext, watchId?: number): Promise { - if (this.disposed) { - return Promise.reject(new Error('relay_watcher_shutdown_fenced')) - } const rootKey = normalizeRuntimePathForComparison(rootPath) if (this.removalFence.isActive(rootKey)) { return Promise.reject(new Error('Remote worktree deletion already in progress')) @@ -75,9 +71,7 @@ export class RelayFilesystemWatchRegistry { if (watchId !== undefined) { existing.clientWatchIds.set(clientId, watchId) } - return awaitRelayWatcherSetupForClient(existing, context, () => - this.releaseWatchClient(existing, clientId) - ) + return this.awaitSetupForClient(existing, clientId, context) } void this.closeWatch(existing).catch(() => {}) } @@ -110,9 +104,6 @@ export class RelayFilesystemWatchRegistry { if (capacityRelease) { await capacityRelease } - if (this.disposed) { - throw new Error('relay_watcher_shutdown_fenced') - } const clientId = context?.clientId ?? 0 const isStale = context?.isStale ?? (() => false) const existing = this.watches.get(rootKey) @@ -121,9 +112,7 @@ export class RelayFilesystemWatchRegistry { if (watchId !== undefined) { existing.clientWatchIds.set(clientId, watchId) } - await awaitRelayWatcherSetupForClient(existing, context, () => - this.releaseWatchClient(existing, clientId) - ) + await this.awaitSetupForClient(existing, clientId, context) return } @@ -132,9 +121,7 @@ export class RelayFilesystemWatchRegistry { const state = createRelayWatcherState(rootKey, rootPath, clientId, isStale, watchId) this.watches.set(rootKey, state) state.setupWaiters = new PromiseSettlementWaiters(this.startInitialWatch(state)) - await awaitRelayWatcherSetupForClient(state, context, () => - this.releaseWatchClient(state, clientId) - ) + await this.awaitSetupForClient(state, clientId, context) } unwatch(rootPath: string, context?: RequestContext): void { @@ -196,31 +183,27 @@ export class RelayFilesystemWatchRegistry { } dispose(): void { - this.disposed = true this.watches.forEach((state) => void this.closeWatch(state).catch(() => {})) this.watcherPool.dispose() } - closeWatchesAndWait(): Promise { - this.disposed = true - return closeRelayWatchesAndWait( - this.watches, - this.pendingSetups, - this.teardownTracker, - (state) => this.closeWatch(state) - ) - } - - disposeAndWait = (): Promise => - disposeRelayWatchesAndWait(() => this.closeWatchesAndWait(), this.watcherPool) - - private startInitialWatch(state: RelayWatcherTeardownState): Promise { - return startInitialRelayWatch( - state, - () => this.subscribeState(state), - () => emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed), - () => this.closeWatch(state) - ) + private async startInitialWatch(state: RelayWatcherTeardownState): Promise { + try { + await this.subscribeState(state) + } catch (firstError) { + if (!state.closed && shouldRetryInitialRelayWatch(firstError)) { + try { + await this.subscribeState(state) + emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed) + return + } catch (quarantineError) { + void this.closeWatch(state).catch(() => {}) + throw quarantineError + } + } + void this.closeWatch(state).catch(() => {}) + throw firstError + } } private subscribeState(state: RelayWatcherTeardownState): Promise { @@ -262,13 +245,7 @@ export class RelayFilesystemWatchRegistry { state.generation !== generation || this.watches.get(state.rootKey) !== state ) { - if (state.closed) { - state.subscription = subscription - } await subscription.unsubscribe() - if (state.subscription === subscription) { - state.subscription = null - } return } state.subscription = subscription @@ -299,6 +276,30 @@ export class RelayFilesystemWatchRegistry { }) } + private async awaitSetupForClient( + state: RelayWatcherTeardownState, + clientId: number, + context?: RequestContext + ): Promise { + try { + await awaitRelayWatcherSetup(state.setupWaiters, context?.signal) + } catch (error) { + this.releaseWatchClient(state, clientId) + const expectedAbort = + (error instanceof Error && error.name === 'AbortError') || + (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted') + if (expectedAbort) { + return + } + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`) + throw error + } + if (context?.isStale()) { + this.releaseWatchClient(state, clientId) + } + } + private releaseClientWatches(clientId: number): void { this.watches.forEach((state) => this.releaseWatchClient(state, clientId)) } diff --git a/src/relay/relay-grace-lifecycle.test.ts b/src/relay/relay-grace-lifecycle.test.ts deleted file mode 100644 index 499d87dd34c..00000000000 --- a/src/relay/relay-grace-lifecycle.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { PtyHandler } from './pty-handler' -import { RelayGraceLifecycle } from './relay-grace-lifecycle' - -afterEach(() => { - vi.restoreAllMocks() -}) - -function fixture(options: { clients?: number; dispose?: () => Promise } = {}) { - const graceCallbacks: (() => void)[] = [] - const ptyHandler = { - configuredGraceTimeMs: 1_000, - activePtyCount: 0, - pendingPtyCreationCount: 0, - graceTimerActive: false, - startGraceTimer: vi.fn((callback: () => void) => graceCallbacks.push(callback)), - cancelGraceTimer: vi.fn(), - onPtyPoolEmpty: vi.fn(() => () => {}), - onPtyPoolActive: vi.fn(() => () => {}), - dispose: vi.fn(options.dispose ?? (async () => {})) - } - const dispatcher = { - onNotification: vi.fn(), - onRequest: vi.fn(), - assertActiveWorkContext: vi.fn(), - beginWorkDrain: vi.fn(async () => {}) - } - const disposeOwnedProcesses = vi.fn(async () => {}) - const disposeRuntime = vi.fn() - const lifecycle = new RelayGraceLifecycle({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every dispatcher member the lifecycle calls. - dispatcher: dispatcher as unknown as RelayDispatcher, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every PTY handler member the lifecycle calls. - ptyHandler: ptyHandler as unknown as PtyHandler, - detached: true, - emptyDetachedStartupGraceMs: 100, - idleRelayGraceMs: 100, - readSocketClientCount: () => options.clients ?? 0, - hasAcceptedSocketClient: () => false, - ownsSocketPath: () => true, - disposeOwnedProcesses, - disposeRuntime - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: process.exit never returns; the stub only records the call. - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) - return { - lifecycle, - ptyHandler, - dispatcher, - disposeOwnedProcesses, - disposeRuntime, - exit, - graceCallbacks - } -} - -it('exits after idle shutdown without waiting on admitted requests', async () => { - const f = fixture() - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.exit).toHaveBeenCalledWith(0)) - expect(f.ptyHandler.dispose).toHaveBeenCalledOnce() - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() - expect(f.disposeRuntime).toHaveBeenCalledOnce() - expect(f.dispatcher.beginWorkDrain).not.toHaveBeenCalled() -}) - -it('defers a failed idle shutdown and retries it through the grace timer', async () => { - let attempts = 0 - const f = fixture({ - dispose: async () => { - attempts += 1 - if (attempts === 1) { - throw new Error('pty still exiting') - } - } - }) - vi.spyOn(console, 'error').mockImplementation(() => {}) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.ptyHandler.startGraceTimer).toHaveBeenCalledOnce()) - expect(f.lifecycle.reason).toBe('shutdown deferred') - expect(f.exit).not.toHaveBeenCalled() - - f.graceCallbacks[0]() - await vi.waitFor(() => expect(f.exit).toHaveBeenCalledWith(0)) - expect(f.ptyHandler.dispose).toHaveBeenCalledTimes(2) -}) - -it('does not retry a deferred shutdown while a socket client is attached', async () => { - const f = fixture({ - clients: 1, - dispose: async () => { - throw new Error('pty still exiting') - } - }) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.ptyHandler.dispose).toHaveBeenCalledOnce()) - await Promise.resolve() - expect(f.ptyHandler.startGraceTimer).not.toHaveBeenCalled() - expect(f.exit).not.toHaveBeenCalled() -}) - -it('keeps the transport up until a reset initiator finishes, draining around owned disposal', async () => { - const f = fixture() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - await f.lifecycle.prepareShutdown(initiator) - expect(f.dispatcher.assertActiveWorkContext).toHaveBeenCalledWith(initiator) - expect(f.dispatcher.beginWorkDrain).toHaveBeenCalledTimes(2) - expect(f.dispatcher.beginWorkDrain).toHaveBeenCalledWith(initiator) - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() - expect(f.disposeRuntime).not.toHaveBeenCalled() - expect(f.exit).not.toHaveBeenCalled() - - f.lifecycle.finishShutdown() - expect(f.disposeRuntime).toHaveBeenCalledOnce() - expect(f.exit).toHaveBeenCalledWith(0) -}) - -it('joins the same initiator, refuses a different one, and requires preparation to finish', async () => { - const f = fixture() - expect(() => f.lifecycle.finishShutdown()).toThrow('relay_shutdown_preparation_required') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - const first = f.lifecycle.prepareShutdown(initiator) - expect(f.lifecycle.prepareShutdown(initiator)).toBe(first) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const other = { clientId: 2 } as RequestContext - await expect(f.lifecycle.prepareShutdown(other)).rejects.toThrow( - 'relay_shutdown_preparation_in_progress' - ) - await first - expect(f.ptyHandler.dispose).toHaveBeenCalledOnce() -}) - -it('lets a failed reset preparation be retried', async () => { - let attempts = 0 - const f = fixture({ - dispose: async () => { - attempts += 1 - if (attempts === 1) { - throw new Error('pty still exiting') - } - } - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - await expect(f.lifecycle.prepareShutdown(initiator)).rejects.toThrow('pty still exiting') - expect(f.disposeOwnedProcesses).not.toHaveBeenCalled() - await f.lifecycle.prepareShutdown(initiator) - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() -}) diff --git a/src/relay/relay-grace-lifecycle.ts b/src/relay/relay-grace-lifecycle.ts index b32578fa9d4..116b8873115 100644 --- a/src/relay/relay-grace-lifecycle.ts +++ b/src/relay/relay-grace-lifecycle.ts @@ -6,7 +6,7 @@ import { } from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher } from './dispatcher' type RelayGraceLifecycleOptions = { dispatcher: RelayDispatcher @@ -26,9 +26,6 @@ export class RelayGraceLifecycle { private graceReason: string | null = null private graceBranch: RelayGraceBranch | null = null private shutdownInFlight = false - private shutdownPreparation: Promise | null = null - private shutdownPrepared = false - private shutdownInitiator: RequestContext | undefined private stopPoolWatch = (): void => {} private stopPoolActiveWatch = (): void => {} @@ -116,11 +113,22 @@ export class RelayGraceLifecycle { if (this.shutdownInFlight) { return } + this.shutdownInFlight = true relayLogLine( `[relay] Shutdown: ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}, ownsSocket=${this.options.ownsSocketPath()}` ) - void this.prepareShutdown() - .then(() => this.finishShutdown()) + this.graceDeadlineAt = null + this.graceReason = null + this.graceBranch = null + void this.options.ptyHandler + .dispose() + .then(async () => { + await this.options.disposeOwnedProcesses() + this.stopPoolWatch() + this.stopPoolActiveWatch() + this.options.disposeRuntime() + process.exit(0) + }) .catch((error) => { this.shutdownInFlight = false relayLogLine( @@ -132,79 +140,6 @@ export class RelayGraceLifecycle { }) } - /** - * Disposes PTYs and owned processes but leaves the transport up, so a host-owned reset can - * settle its response before {@link finishShutdown} exits. Only a reset initiator also drains - * admitted requests; idle and signal shutdown keep exiting without waiting on them. - */ - prepareShutdown(initiator?: RequestContext, onAdmitted?: () => void): Promise { - if (initiator) { - try { - this.options.dispatcher.assertActiveWorkContext(initiator) - } catch (error) { - return Promise.reject(error) - } - } - if (this.shutdownPreparation) { - if (initiator && initiator !== this.shutdownInitiator) { - return Promise.reject(new Error('relay_shutdown_preparation_in_progress')) - } - return this.shutdownPreparation - } - try { - onAdmitted?.() - } catch (error) { - return Promise.reject(error) - } - this.shutdownInFlight = true - this.shutdownInitiator = initiator - let drainage: Promise - let disposal: Promise - try { - this.cancel('shutdown preparation') - drainage = initiator ? this.options.dispatcher.beginWorkDrain(initiator) : Promise.resolve() - disposal = this.options.ptyHandler.dispose() - } catch (error) { - this.shutdownInFlight = false - this.shutdownInitiator = undefined - return Promise.reject(error) - } - const preparation = Promise.allSettled([drainage, disposal]).then(async (results) => { - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (failures.length === 1) { - throw failures[0] - } - if (failures.length > 1) { - throw new AggregateError(failures, 'relay_shutdown_admitted_work_incomplete') - } - await this.options.disposeOwnedProcesses() - if (initiator) { - // Cleanup controls may have arrived while owned producers were settling. - await this.options.dispatcher.beginWorkDrain(initiator) - } - this.shutdownPrepared = true - }) - this.shutdownPreparation = preparation.catch((error: unknown) => { - this.shutdownPreparation = null - this.shutdownInitiator = undefined - this.shutdownInFlight = false - throw error - }) - return this.shutdownPreparation - } - - finishShutdown(): void { - if (!this.shutdownPrepared) { - throw new Error('relay_shutdown_preparation_required') - } - this.stopPoolWatch() - this.stopPoolActiveWatch() - this.options.disposeRuntime() - process.exit(0) - } - private configure(params: Record): { graceTimeMs: number } { return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { readConfiguredGraceMs: () => this.options.ptyHandler.configuredGraceTimeMs, diff --git a/src/relay/relay-owner-reset-dispatch.test.ts b/src/relay/relay-owner-reset-dispatch.test.ts deleted file mode 100644 index b3fa8fef34f..00000000000 --- a/src/relay/relay-owner-reset-dispatch.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' -import { RelayGraceLifecycle } from './relay-grace-lifecycle' -import { RelayOwnerReset } from './relay-owner-reset' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import type { PtyHandler } from './pty-handler' -import { encodeJsonRpcFrame } from './protocol' -import { RELAY_PREPARED_RESET_RECOVERY_METHOD } from '../shared/relay-owner-reset-contract' - -let dispatcher: RelayDispatcher | undefined -afterEach(() => { - dispatcher?.dispose() - vi.restoreAllMocks() -}) - -it.each(['same-transport', 'reconnect', 'new-coordinator'])( - 'replays prepared reset through %s without repeating real lifecycle disposal', - async (mode) => { - const writes: { - message: Record - settle: (result: SinkWriteSettlement) => void - }[] = [] - const write: ConstructorParameters[0] = (data, settle) => { - const length = data.readUInt32BE(9) - writes.push({ message: JSON.parse(data.subarray(13, 13 + length).toString()), settle }) - return true - } - const identity = { - principal: 'owner', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' as const - } - dispatcher = new RelayDispatcher(write, { supportsWriteCallback: true }, identity) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build') - const dispose = vi.fn(async () => {}) - const owned = vi.fn(async () => {}) - const runtime = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: process.exit never returns; the stub only records the call. - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) - const lifecycle = new RelayGraceLifecycle({ - dispatcher, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every PTY handler member the lifecycle calls. - ptyHandler: { - cancelGraceTimer: vi.fn(), - dispose - } as unknown as PtyHandler, - detached: true, - emptyDetachedStartupGraceMs: 100, - idleRelayGraceMs: 100, - readSocketClientCount: () => 1, - hasAcceptedSocketClient: () => true, - ownsSocketPath: () => true, - disposeOwnedProcesses: owned, - disposeRuntime: runtime - }) - const reset = new RelayOwnerReset({ owners: adapter, lifecycle, ownsEndpoint: () => true }) - dispatcher.onRequest('relay.reset', (params, context) => reset.prepare(params, context)) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - const mutation = vi.fn(async () => {}) - dispatcher.onRequest('fs.writeFile', mutation) - let seq = 0 - let clientId = dispatcher.activeClientIds()[0] - const send = (method: string, params: Record, id?: number) => - dispatcher!.feedClient( - clientId, - encodeJsonRpcFrame( - { jsonrpc: '2.0', method, params, ...(id === undefined ? {} : { id }) }, - ++seq, - 0 - ) - ) - send( - 'pty.openClient', - { protocolVersion: 1, clientInstanceId: 'desktop', requestedRole: 'session-owner' }, - 1 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 1)).toBe(true)) - const grant = writes.find((write) => write.message.id === 1)! - grant.settle({ ok: true }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pty.openClient's grant is a plain JSON record on this wire. - const owner = grant.message.result as Record - const params = { - version: 1, - runtimeIncarnation: reset.runtimeIncarnation, - operationId: 'reset-1', - ownerGeneration: owner.ownerGeneration, - ownerLease: owner.ownerLease - } - send('relay.reset', params, 2) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 2)).toBe(true)) - expect(dispose).toHaveBeenCalledOnce() - expect(owned).toHaveBeenCalledOnce() - send('rpc.cancel', { id: 2 }) - writes.find((write) => write.message.id === 2)!.settle({ ok: true }) - expect(exit).not.toHaveBeenCalled() - if (mode !== 'same-transport') { - dispatcher.invalidateClient('peer-closed') - clientId = dispatcher.attachClient(write, { supportsWriteCallback: true }, identity) - expect(adapter.activeSessionOwner(clientId)).toBeNull() - send( - 'pty.openClient', - { - protocolVersion: 1, - clientInstanceId: 'desktop', - requestedRole: 'session-owner' - }, - 22 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 22)).toBe(true)) - const fencedGrant = writes.find((write) => write.message.id === 22)! - expect(fencedGrant.message.error).toMatchObject({ message: 'relay_work_admission_closed' }) - fencedGrant.settle({ ok: true }) - expect(adapter.activeSessionOwner(clientId)).toBeNull() - if (mode === 'new-coordinator') { - const replacement = new RelayOwnerReset({ - owners: adapter, - lifecycle, - ownsEndpoint: () => true - }) - expect(replacement.runtimeIncarnation).not.toBe(reset.runtimeIncarnation) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - replacement.recoverPrepared(params, context) - ) - for (const [id, runtimeIncarnation] of [ - [20, reset.runtimeIncarnation], - [21, replacement.runtimeIncarnation] - ] as const) { - send(RELAY_PREPARED_RESET_RECOVERY_METHOD, { ...params, runtimeIncarnation }, id) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === id)).toBe(true)) - const refusal = writes.find((write) => write.message.id === id)! - expect(refusal.message.error).toMatchObject({ - message: 'relay_reset_continuation_unauthorized' - }) - refusal.settle({ ok: true }) - expect(exit).not.toHaveBeenCalled() - } - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - } - } - send( - mode === 'same-transport' ? 'relay.reset' : RELAY_PREPARED_RESET_RECOVERY_METHOD, - params, - 3 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 3)).toBe(true)) - const retry = writes.find((write) => write.message.id === 3)! - expect(retry.message.result).toEqual({ - version: 1, - operationId: params.operationId, - runtimeIncarnation: reset.runtimeIncarnation, - prepared: true - }) - expect(dispose).toHaveBeenCalledOnce() - expect(owned).toHaveBeenCalledOnce() - send('fs.writeFile', {}, 4) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 4)).toBe(true)) - expect(mutation).not.toHaveBeenCalled() - expect(runtime).not.toHaveBeenCalled() - retry.settle({ ok: true }) - expect(runtime).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(0) - } -) diff --git a/src/relay/relay-owner-reset-preparation-journal.test.ts b/src/relay/relay-owner-reset-preparation-journal.test.ts deleted file mode 100644 index 2a572aa1bd8..00000000000 --- a/src/relay/relay-owner-reset-preparation-journal.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { tmpdir } from 'node:os' -import { beforeEach, afterEach, expect, it, vi } from 'vitest' -import * as secure from '../shared/secure-file' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' - -let directory: string -const request = { - version: 1 as const, - operationId: 'operation', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'secret' -} -const store = () => new RelayOwnerResetPreparationJournal(directory, '/socket', 'build') -const persist = () => store().persist(request, 'principal', 'endpoint-credential', () => {}) -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-preparation-')) -}) -afterEach(() => { - vi.restoreAllMocks() - rmSync(directory, { recursive: true, force: true }) -}) - -it('advertises an absolute journal location without creating files', () => { - const journal = new RelayOwnerResetPreparationJournal('relative-journal', '/socket', 'build') - expect(journal.describe('owner', 'endpoint-credential')).toMatchObject({ - journalDirectory: resolve('relative-journal'), - readerVersion: 1, - principal: 'owner', - sockPath: '/socket', - serverBuildId: 'build' - }) -}) - -it('retains exact preparation across store recreation without claiming process exit', () => { - expect(store().read(request)).toBeNull() - persist() - const record = store().read(request) - expect(record).toEqual({ - version: 1, - prepared: true, - request, - principal: 'principal', - authenticationKind: 'endpoint-credential', - sockPath: '/socket', - serverBuildId: 'build' - }) - expect(Object.isFrozen(record)).toBe(true) - expect(Object.isFrozen(record!.request)).toBe(true) - expect(readdirSync(directory)[0]).not.toContain('secret') -}) - -it('reflushes exact retry after a write published but its return was lost', () => { - const write = secure.writeDurableSecureJsonFile - const spy = vi - .spyOn(secure, 'writeDurableSecureJsonFile') - .mockImplementationOnce((path, record) => { - write(path, record) - throw new Error('return lost') - }) - expect(persist).toThrow('return lost') - expect(store().read(request)?.prepared).toBe(true) - persist() - expect(spy).toHaveBeenCalledTimes(2) -}) - -it('does not overwrite a conflicting principal or operation owner', () => { - persist() - const path = join(directory, readdirSync(directory)[0]) - const original = readFileSync(path, 'utf8') - expect(() => store().persist(request, 'other', 'endpoint-credential', () => {})).toThrow( - 'conflict' - ) - expect(() => - store().persist( - { ...request, ownerLease: 'other' }, - 'principal', - 'endpoint-credential', - () => {} - ) - ).toThrow('conflict') - expect(readFileSync(path, 'utf8')).toBe(original) -}) - -it('refuses a different socket or build even with matching operation identifiers', () => { - persist() - expect(() => - new RelayOwnerResetPreparationJournal(directory, '/other', 'build').read(request) - ).toThrow('conflict') - expect(() => - new RelayOwnerResetPreparationJournal(directory, '/socket', 'other').read(request) - ).toThrow('conflict') -}) - -it('does not grant preparation when the secure write cannot be confirmed', () => { - vi.spyOn(secure, 'writeDurableSecureJsonFile').mockReturnValue(false) - expect(persist).toThrow('write_unconfirmed') - expect(store().read(request)).toBeNull() -}) - -it('preserves corrupt evidence rather than overwriting it on retry', () => { - persist() - const path = join(directory, readdirSync(directory)[0]) - writeFileSync(path, '{broken') - expect(persist).toThrow() - expect(readFileSync(path, 'utf8')).toBe('{broken') -}) - -it('refuses reentrant publication through a second store instance', () => { - const authority = () => expect(persist).toThrow('busy') - store().persist(request, 'principal', 'endpoint-credential', authority) - expect(store().read(request)?.prepared).toBe(true) -}) - -it('checks authority before any write and again after publication', () => { - const write = vi.spyOn(secure, 'writeDurableSecureJsonFile') - expect(() => - store().persist(request, 'principal', 'endpoint-credential', () => { - throw new Error('stale') - }) - ).toThrow('stale') - expect(write).not.toHaveBeenCalled() - let checks = 0 - expect(() => - store().persist(request, 'principal', 'endpoint-credential', () => { - if (++checks === 3) { - throw new Error('stale after write') - } - }) - ).toThrow('stale after write') - expect(store().read(request)?.prepared).toBe(true) -}) diff --git a/src/relay/relay-owner-reset-preparation-journal.ts b/src/relay/relay-owner-reset-preparation-journal.ts deleted file mode 100644 index b22c750caba..00000000000 --- a/src/relay/relay-owner-reset-preparation-journal.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createHash } from 'node:crypto' -import { lstatSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { writeDurableSecureJsonFile } from '../shared/secure-file' -import { - parseRelayOwnerResetRequest, - type RelayOwnerResetRequest -} from '../shared/relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - parseRelayResetPreparationRecord as parsePreparation, - type RelayResetPreparationRecord as Preparation -} from '../shared/relay-reset-preparation-contract' -const writing = new Set() - -/** Immutable preparation evidence, not evidence that the daemon or its processes exited. */ -export class RelayOwnerResetPreparationJournal { - private readonly directory: string - - constructor( - directory: string, - private readonly sockPath: string, - private readonly serverBuildId: string - ) { - this.directory = resolve(directory) - } - - describe(principal: string, authenticationKind: string) { - return parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: this.directory, - readerVersion: 1, - sockPath: this.sockPath, - serverBuildId: this.serverBuildId, - principal, - authenticationKind - }) - } - - private path(request: RelayOwnerResetRequest): string { - const hash = createHash('sha256') - .update(JSON.stringify([request.runtimeIncarnation, request.operationId])) - .digest('hex') - return join(this.directory, `${hash}.json`) - } - - read(value: RelayOwnerResetRequest): Preparation | null { - const request = parseRelayOwnerResetRequest(value) - const path = this.path(request) - try { - const stat = lstatSync(path) - if (!stat.isFile() || stat.size > 64 * 1024) { - throw new Error('relay_reset_preparation_journal_invalid') - } - const record = parsePreparation( - JSON.parse(readNodeFileSyncWithinLimit(path, 64 * 1024).buffer.toString('utf8')) - ) - if ( - JSON.stringify(record.request) !== JSON.stringify(request) || - record.sockPath !== this.sockPath || - record.serverBuildId !== this.serverBuildId - ) { - throw new Error('relay_reset_preparation_journal_conflict') - } - return record - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return null - } - throw error - } - } - - persist( - request: RelayOwnerResetRequest, - principal: string, - authenticationKind: string, - assertAuthority: () => void - ): undefined { - const record = parsePreparation({ - version: 1, - prepared: true, - request, - principal, - authenticationKind, - sockPath: this.sockPath, - serverBuildId: this.serverBuildId - }) - const path = this.path(record.request) - if (writing.has(path)) { - throw new Error('relay_reset_preparation_journal_busy') - } - writing.add(path) - try { - assertAuthority() - const previous = this.read(record.request) - if (previous && JSON.stringify(previous) !== JSON.stringify(record)) { - throw new Error('relay_reset_preparation_journal_conflict') - } - assertAuthority() - if (!writeDurableSecureJsonFile(path, record)) { - throw new Error('relay_reset_preparation_journal_write_unconfirmed') - } - assertAuthority() - if (JSON.stringify(this.read(record.request)) !== JSON.stringify(record)) { - throw new Error('relay_reset_preparation_journal_write_unconfirmed') - } - } finally { - writing.delete(path) - } - } -} diff --git a/src/relay/relay-owner-reset-registration.test.ts b/src/relay/relay-owner-reset-registration.test.ts deleted file mode 100644 index dc757b4db99..00000000000 --- a/src/relay/relay-owner-reset-registration.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { Server } from 'node:net' -import type { MethodHandler, RequestContext } from './dispatcher' -import { registerRelayOwnerReset } from './relay-owner-reset-registration' -import { parseRelayResetPreparationBinding } from '../shared/relay-reset-preparation-contract' -import { - RELAY_OWNER_RESET_CAPABILITY, - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from '../shared/relay-owner-reset-contract' - -function connection(clientId = 1) { - let callback: Parameters>[0] | undefined - const context: RequestContext = { - clientId, - transportGeneration: clientId, - isStale: () => false, - sessionIdentity: { - principal: 'owner', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' - }, - onResponseSettled: (settle) => { - callback = settle - } - } - return { context, settle: () => callback!({ ok: true }) } -} - -function fixture( - runtimeIncarnation?: string, - persistPrepared?: Parameters[1]['persistPrepared'], - describePreparation?: Parameters[1]['describePreparation'] -) { - const methods = new Map() - const owner = { ownerGeneration: 1, ownerLease: 'lease' } - const owners = { - activeSessionOwner: vi.fn((): typeof owner | null => owner), - assertOwnerPublicationSettled: vi.fn() - } - const lifecycle = { - prepareShutdown: vi.fn(async (_context?: RequestContext, admitted?: () => void) => { - admitted?.() - }), - finishShutdown: vi.fn() - } - const server = { listening: true } - const socket = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub exposes only the listening flag the registration reads. - server: server as Server | null, - ownsCurrentPath: vi.fn(() => true) - } - const status = registerRelayOwnerReset( - { onRequest: (name, handler) => methods.set(name, handler) }, - { owners, lifecycle, socket, runtimeIncarnation, persistPrepared, describePreparation } - ) - const params = { - version: 1, - operationId: 'reset-1', - runtimeIncarnation: status().ownerReset!.runtimeIncarnation, - ...owner - } - const call = (method: string, context: RequestContext) => methods.get(method)!(params, context) - return { methods, owners, lifecycle, server, socket, status, params, call } -} - -it('publishes coordinates bound to the requesting authenticated owner only', () => { - const describe = vi.fn((principal: string, authenticationKind: string) => - parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: '/journal', - sockPath: '/socket', - serverBuildId: 'build', - principal, - authenticationKind - }) - ) - const f = fixture(undefined, () => undefined, describe) - const { context } = connection() - expect(f.status(context).ownerReset?.preparation).toMatchObject({ - principal: 'owner', - journalDirectory: '/journal' - }) - expect(f.status().ownerReset?.preparation).toBeUndefined() - expect(f.status({ ...context, isStale: () => true }).ownerReset?.preparation).toBeUndefined() - expect( - f.status({ ...context, sessionIdentity: undefined }).ownerReset?.preparation - ).toBeUndefined() - describe.mockClear() - f.server.listening = false - expect(f.status(context)).toEqual({ capabilities: [] }) - expect(describe).not.toHaveBeenCalled() -}) - -it('advertises durable preparation only when the journal writer is installed', () => { - expect(fixture().status().capabilities).not.toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - const f = fixture(undefined, () => undefined) - expect(f.status().capabilities).toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - f.server.listening = false - expect(f.status().capabilities).not.toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) -}) - -it('registers both operations before advertising a stable incarnation', () => { - const f = fixture() - expect([...f.methods.keys()]).toEqual([ - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD - ]) - expect(f.status()).toEqual({ - capabilities: [RELAY_OWNER_RESET_CAPABILITY], - ownerReset: { version: 1, runtimeIncarnation: expect.any(String) } - }) - expect(f.status()).toEqual(f.status()) - expect(f.params.runtimeIncarnation).not.toBe('') -}) - -it('uses the daemon incarnation shared with network tunnel ownership', () => { - const f = fixture('shared-daemon-incarnation') - expect(f.status().ownerReset?.runtimeIncarnation).toBe('shared-daemon-incarnation') -}) - -it.each(['missing-server', 'not-listening', 'lost-path'])( - 'withdraws capability and refuses initial reset on %s', - async (failure) => { - const f = fixture() - if (failure === 'missing-server') { - f.socket.server = null - } else if (failure === 'not-listening') { - f.server.listening = false - } else { - f.socket.ownsCurrentPath.mockReturnValue(false) - } - expect(f.status()).toEqual({ capabilities: [] }) - await expect(f.call(RELAY_OWNER_RESET_METHOD, connection().context)).rejects.toThrow() - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('reuses the same incarnation and prepared result across authenticated reconnect', async () => { - const f = fixture() - const result = await f.call(RELAY_OWNER_RESET_METHOD, connection().context) - f.owners.activeSessionOwner.mockReturnValue(null) - const recovered = connection(2) - await expect(f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, recovered.context)).resolves.toEqual( - result - ) - expect(f.status().ownerReset!.runtimeIncarnation).toBe(f.params.runtimeIncarnation) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - recovered.settle() - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) - -it.each(['initial', 'recovery'] as const)( - 'refuses %s settlement after endpoint ownership disappears', - async (mode) => { - const f = fixture() - const initial = connection() - await f.call(RELAY_OWNER_RESET_METHOD, initial.context) - const response = mode === 'initial' ? initial : connection(2) - if (mode === 'recovery') { - await f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, response.context) - } - f.socket.ownsCurrentPath.mockReturnValue(false) - expect(() => response.settle()).toThrow() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - expect(f.status()).toEqual({ capabilities: [] }) - } -) - -it('refuses prepared recovery when endpoint stops listening and allows exact retry after restoration', async () => { - const f = fixture() - await f.call(RELAY_OWNER_RESET_METHOD, connection().context) - f.server.listening = false - await expect( - f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, connection(2).context) - ).rejects.toThrow() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.server.listening = true - const retry = connection(3) - await f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, retry.context) - retry.settle() - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) diff --git a/src/relay/relay-owner-reset-registration.ts b/src/relay/relay-owner-reset-registration.ts deleted file mode 100644 index ae3b7e6b189..00000000000 --- a/src/relay/relay-owner-reset-registration.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { RelayResetPreparationBinding } from '../shared/relay-reset-preparation-contract' -import type { RelayGraceLifecycle } from './relay-grace-lifecycle' -import type { RelaySocketOwnership } from './relay-socket-ownership' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { RelayOwnerReset } from './relay-owner-reset' -import { - RELAY_OWNER_RESET_CAPABILITY, - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from '../shared/relay-owner-reset-contract' - -export function registerRelayOwnerReset( - dispatcher: Pick, - options: { - runtimeIncarnation?: string - owners: Pick< - SshPtyConsumerSessionAdapter, - 'activeSessionOwner' | 'assertOwnerPublicationSettled' - > - lifecycle: Pick - persistPrepared?: ConstructorParameters[0]['persistPrepared'] - describePreparation?: ( - principal: string, - authenticationKind: string - ) => RelayResetPreparationBinding - socket: Pick - } -): (context?: RequestContext) => { - capabilities: string[] - ownerReset?: { - version: 1 - runtimeIncarnation: string - preparation?: RelayResetPreparationBinding - } -} { - const ownsEndpoint = () => - options.socket.server?.listening === true && options.socket.ownsCurrentPath() - const reset = new RelayOwnerReset({ ...options, ownsEndpoint }) - dispatcher.onRequest(RELAY_OWNER_RESET_METHOD, (params, context) => - reset.prepare(params, context) - ) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - return (context) => { - if (!ownsEndpoint()) { - return { capabilities: [] } - } - const identity = context?.sessionIdentity - const preparation = - options.persistPrepared && - identity?.authenticated && - identity.allowSessionOwner && - ['launch-nonce', 'endpoint-credential'].includes(identity.authenticationKind) && - !context!.isStale() && - !context!.signal?.aborted - ? options.describePreparation?.(identity.principal, identity.authenticationKind) - : undefined - return ownsEndpoint() - ? { - capabilities: [ - RELAY_OWNER_RESET_CAPABILITY, - ...(options.persistPrepared ? [RELAY_DURABLE_RESET_PREPARATION_CAPABILITY] : []) - ], - ownerReset: { - version: 1, - runtimeIncarnation: reset.runtimeIncarnation, - ...(preparation ? { preparation } : {}) - } - } - : { capabilities: [] } - } -} diff --git a/src/relay/relay-owner-reset.test.ts b/src/relay/relay-owner-reset.test.ts deleted file mode 100644 index 4b7449be98a..00000000000 --- a/src/relay/relay-owner-reset.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { RequestContext } from './dispatcher' -import { RelayOwnerReset } from './relay-owner-reset' - -function fixture( - persistPrepared?: ConstructorParameters[0]['persistPrepared'] -) { - let settlement: Parameters>[0] | undefined - const context: RequestContext = { - clientId: 1, - isStale: () => false, - sessionIdentity: { - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential', - principal: 'owner' - }, - onResponseSettled: (callback) => { - settlement = callback - } - } - const owner = { ownerGeneration: 1, ownerLease: 'lease' } - const owners = { - activeSessionOwner: vi.fn((): typeof owner | null => owner), - assertOwnerPublicationSettled: vi.fn() - } - const lifecycle = { - prepareShutdown: vi.fn(async (_context?: RequestContext, admitted?: () => void) => { - admitted?.() - }), - finishShutdown: vi.fn() - } - const ownsEndpoint = vi.fn(() => true) - const reset = new RelayOwnerReset({ owners, lifecycle, ownsEndpoint, persistPrepared }) - const params = { - version: 1, - operationId: 'reset-1', - runtimeIncarnation: reset.runtimeIncarnation, - ...owner - } - return { - context, - owners, - lifecycle, - ownsEndpoint, - reset, - params, - settle: (ok: boolean) => - settlement?.(ok ? { ok: true } : { ok: false, error: new Error('write failed') }) - } -} - -it('withholds acknowledgment on journal failure and reflushes without repeating preparation', async () => { - const persist = vi - .fn[0]['persistPrepared']>>() - .mockImplementationOnce(() => { - throw new Error('disk unavailable') - }) - .mockReturnValue(undefined) - const f = fixture(persist) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('disk unavailable') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - await expect(f.reset.prepare(f.params, f.context)).resolves.toMatchObject({ prepared: true }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist).toHaveBeenLastCalledWith( - f.params, - 'owner', - 'endpoint-credential', - expect.any(Function) - ) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) - -it('prepared continuation also requires a confirmed journal reflush before acknowledgment', async () => { - const persist = vi - .fn[0]['persistPrepared']>>() - .mockReturnValue(undefined) - const f = fixture(persist) - await f.reset.prepare(f.params, f.context) - f.settle(false) - persist.mockImplementationOnce(() => { - throw new Error('journal uncertain') - }) - expect(() => f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2 })).toThrow( - 'journal uncertain' - ) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - expect(f.reset.recoverPrepared(f.params, { ...f.context, clientId: 3 })).toMatchObject({ - prepared: true - }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledTimes(3) -}) - -it('rejects an asynchronous journal hook rather than acknowledging before durability', async () => { - const write = vi.fn(() => Promise.resolve()) - const f = fixture( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub records writes; the dispatcher only calls it as a function. - write as unknown as NonNullable< - ConstructorParameters[0]['persistPrepared'] - > - ) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('not_synchronous') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('finishes only after successful preparation and successful response settlement', async () => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const result = f.reset.prepare(f.params, f.context) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledWith(f.context, expect.any(Function)) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - pending.resolve() - await expect(result).resolves.toEqual({ - version: 1, - operationId: 'reset-1', - runtimeIncarnation: f.reset.runtimeIncarnation, - prepared: true - }) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.settle(true) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it.each(['unauthenticated', 'subscriber', 'stale', 'endpoint', 'lease', 'callback'])( - 'refuses %s admission before destructive preparation', - async (failure) => { - const f = fixture() - if (failure === 'unauthenticated') { - f.context.sessionIdentity!.authenticated = false - } - if (failure === 'subscriber') { - f.owners.activeSessionOwner.mockReturnValue(null) - } - if (failure === 'stale') { - f.context.isStale = () => true - } - if (failure === 'endpoint') { - f.ownsEndpoint.mockReturnValue(false) - } - if (failure === 'lease') { - f.params.ownerLease = 'another' - } - if (failure === 'callback') { - f.context.onResponseSettled = () => { - throw new Error('registration failed') - } - } - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow() - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - } -) - -it('does not finish on failed preparation even when its error response is written', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValue(new Error('transfer fenced')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('transfer fenced') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('does not infer successful reset from a lost response', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('recovers only a retained prepared result on a fresh authenticated transport without ownership admission', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - f.owners.activeSessionOwner.mockReturnValue(null) - const context = { ...f.context, clientId: 2, transportGeneration: 2 } - expect(f.reset.recoverPrepared(f.params, context)).toMatchObject({ prepared: true }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it.each(['missing', 'pending', 'failed'])( - 'refuses %s preparation without invoking cleanup from recovery', - async (state) => { - const f = fixture() - const pending = Promise.withResolvers() - let initial: Promise | undefined - if (state === 'pending') { - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - initial = f.reset.prepare(f.params, f.context) - } else if (state === 'failed') { - f.lifecycle.prepareShutdown.mockImplementationOnce(async (_context, admitted) => { - admitted?.() - throw new Error('failed') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('failed') - } - const calls = f.lifecycle.prepareShutdown.mock.calls.length - expect(() => f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2 })).toThrow( - 'relay_reset_continuation_unauthorized' - ) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(calls) - pending.resolve() - await initial - } -) - -it.each(['incarnation', 'lease', 'principal', 'endpoint', 'unauthenticated'])( - 'rejects changed %s in reconnect continuation', - async (changed) => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - const params = { ...f.params } - if (changed === 'incarnation') { - params.runtimeIncarnation = 'old-process' - } - if (changed === 'lease') { - params.ownerLease = 'wrong' - } - if (changed === 'principal') { - f.context.sessionIdentity!.principal = 'other' - } - if (changed === 'endpoint') { - f.ownsEndpoint.mockReturnValue(false) - } - if (changed === 'unauthenticated') { - f.context.sessionIdentity!.authenticated = false - } - expect(() => f.reset.recoverPrepared(params, { ...f.context, clientId: 2 })).toThrow( - 'relay_reset_continuation_unauthorized' - ) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('rechecks the recovery transport at response settlement and preserves later recovery', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - let stale = false - f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2, isStale: () => stale }) - stale = true - expect(() => f.settle(true)).toThrow('relay_reset_continuation_unauthorized') - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.reset.recoverPrepared(f.params, { ...f.context, clientId: 3 }) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it('replays a prepared same-transport retry without repeating destructive cleanup', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - await expect(f.reset.prepare(f.params, { ...f.context })).resolves.toMatchObject({ - prepared: true - }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it('refuses pending duplicates without waiting on the first request', async () => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const first = f.reset.prepare(f.params, f.context) - await expect(f.reset.prepare(f.params, { ...f.context })).rejects.toThrow( - 'relay_reset_preparation_in_progress' - ) - pending.resolve() - await first - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() -}) - -it.each(['operation', 'transport', 'principal'])( - 'refuses changed %s on prepared replay', - async (changed) => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - if (changed === 'operation') { - f.params.operationId = 'different' - } else if (changed === 'transport') { - f.context.transportGeneration = 2 - } else { - f.context.sessionIdentity!.principal = 'different' - } - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow( - 'relay_reset_operation_conflict' - ) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('retries failed cleanup explicitly instead of replaying it as prepared', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('cleanup failed')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('cleanup failed') - await f.reset.prepare(f.params, { ...f.context }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(2) -}) - -it('releases an operation refused before admission so a new operation can proceed', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('transfer fenced')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('transfer fenced') - f.params.operationId = 'new-reset' - await expect(f.reset.prepare(f.params, f.context)).resolves.toMatchObject({ - operationId: 'new-reset' - }) -}) - -it('retains an admitted operation through cleanup failure and a later pre-admission refusal', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockImplementationOnce(async (_context, admitted) => { - admitted?.() - throw new Error('cleanup failed') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('cleanup failed') - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('early retry refusal')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('early retry refusal') - await expect( - f.reset.prepare({ ...f.params, operationId: 'different' }, f.context) - ).rejects.toThrow('relay_reset_operation_conflict') - await f.reset.prepare(f.params, f.context) -}) - -it('allows an explicit prepared replay to retry final shutdown after it throws', async () => { - const f = fixture() - f.lifecycle.finishShutdown.mockImplementationOnce(() => { - throw new Error('finish failed') - }) - await f.reset.prepare(f.params, f.context) - expect(() => f.settle(true)).toThrow('finish failed') - await f.reset.prepare(f.params, { ...f.context }) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(2) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() -}) - -it('refuses a queued replacement grant before starting shutdown', async () => { - const f = fixture() - f.owners.assertOwnerPublicationSettled.mockImplementation(() => { - throw new Error('pty_consumer_owner_publication_pending') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow( - 'pty_consumer_owner_publication_pending' - ) - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it.each(['owner', 'endpoint'])( - 'refuses completion if %s changes during preparation', - async (changed) => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const result = f.reset.prepare(f.params, f.context) - if (changed === 'owner') { - f.owners.activeSessionOwner.mockReturnValue(null) - } else { - f.ownsEndpoint.mockReturnValue(false) - } - pending.resolve() - await expect(result).rejects.toThrow('relay_reset_unauthorized') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) diff --git a/src/relay/relay-owner-reset.ts b/src/relay/relay-owner-reset.ts deleted file mode 100644 index 829d342e3f3..00000000000 --- a/src/relay/relay-owner-reset.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { RequestContext } from './dispatcher' -import { randomUUID } from 'node:crypto' -import type { RelayGraceLifecycle } from './relay-grace-lifecycle' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { - parseRelayOwnerResetRequest, - type RelayOwnerResetRequest, - type RelayOwnerResetAcknowledgment -} from '../shared/relay-owner-reset-contract' - -type ResetOptions = { - runtimeIncarnation?: string - owners: Pick - lifecycle: Pick - ownsEndpoint: () => boolean - persistPrepared?: ( - request: RelayOwnerResetRequest, - principal: string, - authenticationKind: string, - assertAuthority: () => void - ) => undefined -} - -/** Prepared continuation never authorizes new cleanup or ownership admission. */ -export class RelayOwnerReset { - readonly runtimeIncarnation: string - private finished = false - private admitted: { - request: RelayOwnerResetRequest - principal: string - authenticationKind: string - clientId: number - transportGeneration: number | undefined - state: 'pending' | 'prepared' | 'failed' - } | null = null - - constructor(private readonly options: ResetOptions) { - this.runtimeIncarnation = options.runtimeIncarnation ?? randomUUID() - } - - recoverPrepared( - params: Record, - context: RequestContext - ): RelayOwnerResetAcknowledgment { - const request = parseRelayOwnerResetRequest(params) - const expectedIncarnation = request.runtimeIncarnation - const retained = this.admitted - const clientId = context.clientId - const generation = context.transportGeneration - const assertContinuation = (): void => { - const identity = context.sessionIdentity - if ( - !retained || - this.admitted !== retained || - retained.state !== 'prepared' || - expectedIncarnation !== this.runtimeIncarnation || - request.operationId !== retained.request.operationId || - request.ownerGeneration !== retained.request.ownerGeneration || - request.ownerLease !== retained.request.ownerLease || - identity?.authenticated !== true || - identity.allowSessionOwner !== true || - identity.principal !== retained.principal || - identity.authenticationKind !== retained.authenticationKind || - context.isStale() || - context.signal?.aborted || - context.clientId !== clientId || - context.transportGeneration !== generation || - !context.onResponseSettled || - !this.options.ownsEndpoint() - ) { - throw new Error('relay_reset_continuation_unauthorized') - } - } - assertContinuation() - const persisted = this.options.persistPrepared?.( - request, - retained!.principal, - retained!.authenticationKind, - assertContinuation - ) - if (persisted !== undefined) { - throw new Error('relay_reset_preparation_journal_not_synchronous') - } - assertContinuation() - context.onResponseSettled!((settlement) => { - if (!settlement.ok || this.finished) { - return - } - assertContinuation() - this.finish() - }) - assertContinuation() - return { - version: 1, - operationId: request.operationId, - runtimeIncarnation: this.runtimeIncarnation, - prepared: true - } - } - - private finish(): void { - this.finished = true - try { - this.options.lifecycle.finishShutdown() - } catch (error) { - this.finished = false - throw error - } - } - - async prepare( - params: Record, - context: RequestContext - ): Promise { - const request = parseRelayOwnerResetRequest(params) - if (request.runtimeIncarnation !== this.runtimeIncarnation) { - throw new Error('relay_reset_incarnation_mismatch') - } - const principal = context.sessionIdentity?.principal - const authenticationKind = context.sessionIdentity?.authenticationKind - const clientId = context.clientId - const transportGeneration = context.transportGeneration - const assertOwner = (): void => { - const identity = context.sessionIdentity - const owner = this.options.owners.activeSessionOwner(context.clientId) - if ( - context.isStale() || - context.signal?.aborted || - identity?.authenticated !== true || - identity.allowSessionOwner !== true || - !['launch-nonce', 'endpoint-credential'].includes(identity.authenticationKind) || - !principal || - identity.principal !== principal || - identity.authenticationKind !== authenticationKind || - context.clientId !== clientId || - context.transportGeneration !== transportGeneration || - !context.onResponseSettled || - !this.options.ownsEndpoint() || - owner?.ownerGeneration !== request.ownerGeneration || - owner.ownerLease !== request.ownerLease - ) { - throw new Error('relay_reset_unauthorized') - } - } - assertOwner() - const previous = this.admitted - if (previous) { - if ( - previous.request.operationId !== request.operationId || - previous.request.ownerGeneration !== request.ownerGeneration || - previous.request.ownerLease !== request.ownerLease || - previous.principal !== principal || - previous.authenticationKind !== context.sessionIdentity!.authenticationKind || - previous.clientId !== context.clientId || - previous.transportGeneration !== context.transportGeneration - ) { - throw new Error('relay_reset_operation_conflict') - } - if (previous.state === 'pending') { - throw new Error('relay_reset_preparation_in_progress') - } - } - let prepared = false - let preparationRecord: typeof this.admitted = null - context.onResponseSettled!((settlement) => { - if ( - !prepared || - !settlement.ok || - this.finished || - !preparationRecord || - this.admitted !== preparationRecord || - preparationRecord.state !== 'prepared' - ) { - return - } - assertOwner() - this.finish() - }) - assertOwner() - this.options.owners.assertOwnerPublicationSettled() - if (previous?.state !== 'prepared') { - const admitted = previous ?? { - request, - principal: principal!, - authenticationKind: context.sessionIdentity!.authenticationKind, - clientId: context.clientId, - transportGeneration: context.transportGeneration, - state: 'pending' as const - } - this.admitted = admitted - admitted.state = 'pending' - let began = false - try { - await this.options.lifecycle.prepareShutdown(context, () => { - began = true - }) - admitted.state = 'prepared' - } catch (error) { - if (!began && !previous && this.admitted === admitted) { - this.admitted = null - } else { - admitted.state = 'failed' - } - throw error - } - } - assertOwner() - if ( - this.options.persistPrepared?.(request, principal!, authenticationKind!, assertOwner) !== - undefined - ) { - throw new Error('relay_reset_preparation_journal_not_synchronous') - } - assertOwner() - preparationRecord = this.admitted - prepared = true - return { - version: 1, - operationId: request.operationId, - runtimeIncarnation: this.runtimeIncarnation, - prepared: true - } - } -} diff --git a/src/relay/relay-producer-publication-drain.test.ts b/src/relay/relay-producer-publication-drain.test.ts deleted file mode 100644 index 0b1ddbe89a2..00000000000 --- a/src/relay/relay-producer-publication-drain.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' -import { RelayProducerPublicationDrain } from './relay-producer-publication-drain' - -describe('relay producer publication drain', () => { - const dispatchers: RelayDispatcher[] = [] - const setup = (synchronous = false) => { - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settle) => { - writes.push(settle) - if (synchronous) { - settle({ ok: true }) - } - return true - }, - { supportsWriteCallback: true } - ) - dispatchers.push(dispatcher) - const assertAuthority = vi.fn() - const publication = new RelayProducerPublicationDrain(dispatcher, 1, 0, assertAuthority) - return { dispatcher, publication, writes, assertAuthority } - } - afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } - }) - - it('waits for all callbacks, including frames published while waiting', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const done = vi.fn() - const drain = publication.drain(new AbortController().signal).then(done) - publication.publish('tunnel.frame', {}) - writes[0]({ ok: true }) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('not_drained') - writes[1]({ ok: true }) - await drain - expect(done).toHaveBeenCalledOnce() - }) - - it('accounts before synchronous settlement', async () => { - const { publication } = setup(true) - expect(publication.publish('tunnel.frame', {})).toBe(true) - await publication.drain(new AbortController().signal) - expect(() => publication.assertDrained()).not.toThrow() - }) - - it('aborts only observation and retains an ensuing failure for retry', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort(new Error('observer cancelled')) - await expect(drain).rejects.toThrow('observer cancelled') - expect(() => publication.assertDrained()).toThrow('not_drained') - writes[0]({ ok: false, error: new Error('lost write') }) - await expect(publication.drain(new AbortController().signal)).rejects.toThrow('lost write') - expect(publication.publish('tunnel.frame', {})).toBe(false) - expect(writes).toHaveLength(1) - }) - - it('can retry an aborted observation after successful write settlement', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort() - await expect(drain).rejects.toThrow() - writes[0]({ ok: true }) - await publication.drain(new AbortController().signal) - }) - - it('wakes every observer on failure despite outstanding writes', async () => { - const { publication } = setup() - publication.publish('tunnel.frame', {}) - const first = publication.drain(new AbortController().signal) - const second = publication.drain(new AbortController().signal) - publication.fail(new Error('transport lost')) - await expect(first).rejects.toThrow('transport lost') - await expect(second).rejects.toThrow('transport lost') - }) - - it('rejects replacement even when all old writes already completed', () => { - const { publication, dispatcher } = setup(true) - publication.publish('tunnel.frame', {}) - const replacement = vi.fn(() => true) - dispatcher.setWrite(replacement, { supportsWriteCallback: true }) - expect(() => publication.assertDrained()).toThrow('transport_unverifiable') - expect(publication.publish('tunnel.frame', {})).toBe(false) - expect(replacement).not.toHaveBeenCalled() - }) - - it('fails pending writes on replacement and ignores their late success', async () => { - const { publication, dispatcher, writes } = setup() - publication.publish('tunnel.frame', {}) - const drain = publication.drain(new AbortController().signal) - dispatcher.setWrite(() => true, { supportsWriteCallback: true }) - writes[0]({ ok: true }) - await expect(drain).rejects.toThrow('sink replaced') - }) - - it('refuses callback-less sinks, even if they synchronously accept writes', () => { - const dispatcher = new RelayDispatcher(() => true) - dispatchers.push(dispatcher) - expect(() => new RelayProducerPublicationDrain(dispatcher, 1, 0, () => {})).toThrow( - 'write_callback_required' - ) - }) - - it('retains pre-drain serialization failure', async () => { - const { publication, writes } = setup() - const params: Record = {} - params.circular = params - expect(publication.publish('tunnel.frame', params)).toBe(false) - await expect(publication.drain(new AbortController().signal)).rejects.toThrow() - expect(writes).toHaveLength(0) - }) - - it('revalidates generation after serialization before enqueue', () => { - const { publication, dispatcher, writes } = setup() - const replacement = vi.fn(() => true) - expect( - publication.publish('tunnel.frame', { - toJSON: () => { - dispatcher.setWrite(replacement, { supportsWriteCallback: true }) - return {} - } - }) - ).toBe(false) - expect(writes).toHaveLength(0) - expect(replacement).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('transport_unverifiable') - }) - - it('revalidates owner at emission and retains its failure', () => { - const { publication, assertAuthority, writes } = setup() - expect( - publication.publish('tunnel.frame', { - toJSON: () => { - assertAuthority.mockImplementation(() => { - throw new Error('owner changed') - }) - return {} - } - }) - ).toBe(false) - expect(writes).toHaveLength(0) - expect(() => publication.assertDrained()).toThrow('owner changed') - }) - - it('refuses queued frames when ownership changes during backpressure', async () => { - let resume: (() => void) | undefined - let ownerCurrent = true - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settle) => { - writes.push(settle) - return false - }, - { - supportsWriteCallback: true, - waitWriteDrain: (callback) => { - resume = callback - } - } - ) - dispatchers.push(dispatcher) - const publication = new RelayProducerPublicationDrain(dispatcher, 1, 0, () => { - if (!ownerCurrent) { - throw new Error('owner replaced while queued') - } - }) - expect(publication.publish('tunnel.frame', { sequence: 1 })).toBe(true) - expect(publication.publish('tunnel.frame', { sequence: 2 })).toBe(true) - expect(writes).toHaveLength(1) - const drain = publication.drain(new AbortController().signal) - ownerCurrent = false - writes[0]({ ok: true }) - expect(resume).toBeTypeOf('function') - resume!() - await expect(drain).rejects.toThrow('owner replaced while queued') - expect(writes).toHaveLength(1) - }) -}) diff --git a/src/relay/relay-producer-publication-drain.ts b/src/relay/relay-producer-publication-drain.ts deleted file mode 100644 index 8ead8ac105d..00000000000 --- a/src/relay/relay-producer-publication-drain.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { TransportPublicationDrain } from '../shared/transport-publication-drain' -import type { RelayDispatcher } from './dispatcher' - -type PublicationDispatcher = Pick< - RelayDispatcher, - 'publishProducerNotification' | 'assertSettledProducerTransport' -> - -/** Local write completion only; the tunnel separately proves downstream consumption. */ -export class RelayProducerPublicationDrain extends TransportPublicationDrain { - constructor( - private readonly dispatcher: PublicationDispatcher, - private readonly clientId: number, - private readonly transportGeneration: number, - assertAuthority: () => void, - onFailure: (error: Error) => void = () => {} - ) { - super(() => { - assertAuthority() - dispatcher.assertSettledProducerTransport(clientId, transportGeneration) - }, onFailure) - } - - publish(method: string, params: Record): boolean { - const settle = this.trackWrite() - try { - this.assertCurrent() - const accepted = this.dispatcher.publishProducerNotification(this.clientId, method, params, { - logDrop: false, - settledTransportGeneration: this.transportGeneration, - isStillAdmitted: () => { - try { - this.assertCurrent() - return true - } catch { - return false - } - }, - onSettled: settle - }) - if (!accepted) { - settle({ ok: false, error: new Error('relay_producer_publication_refused') }) - } - return accepted && !this.failure - } catch (error) { - settle({ ok: false, error: error instanceof Error ? error : new Error(String(error)) }) - return false - } - } -} diff --git a/src/relay/relay-reset-preparation-reader.integration.test.ts b/src/relay/relay-reset-preparation-reader.integration.test.ts deleted file mode 100644 index 10a2cdc8ee6..00000000000 --- a/src/relay/relay-reset-preparation-reader.integration.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { build } from 'esbuild' -import { mkdtempSync, mkdirSync, readdirSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { beforeAll, afterAll, describe, expect, it } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { RELAY_RESET_PREPARATION_READ_FLAG } from './relay-reset-preparation-reader' - -let directory: string -let entry: string -let emptyPath: string -let envelope: object -beforeAll(async () => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-reader-bundle-')) - entry = join(directory, 'relay.js') - emptyPath = join(directory, 'empty-path') - mkdirSync(emptyPath) - const journal = new RelayOwnerResetPreparationJournal( - join(directory, 'journal'), - join(directory, 'never-created.sock'), - 'old-build' - ) - const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'exited-daemon', - ownerGeneration: 1, - ownerLease: 'private-lease' - } - journal.persist(request, 'owner', 'endpoint-credential', () => {}) - envelope = { version: 1, binding: journal.describe('owner', 'endpoint-credential'), request } - await build({ - entryPoints: [join(__dirname, 'relay.ts')], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: entry, - external: ['node-pty', '@parcel/watcher', 'electron'], - sourcemap: false - }) -}, 30_000) -afterAll(() => { - if (directory) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -describe('Node built reader', () => { - const program = process.execPath - it('reads retained evidence in a fresh process with an empty PATH and no daemon artifacts', async () => { - const before = readdirSync(directory).sort() - const result = await runProcess({ - program, - args: [entry, RELAY_RESET_PREPARATION_READ_FLAG], - cwd: directory, - input: JSON.stringify(envelope), - timeoutMs: 15_000, - maxOutputBytes: 128 * 1024, - env: { - ...process.env, - PATH: emptyPath, - NODE_PATH: '', - NODE_OPTIONS: '', - ORCA_BACKGROUND_LAUNCH: '1' - } - }) - expect(result.timedOut).toBe(false) - expect(result.code, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toMatchObject({ - version: 1, - preparation: { - prepared: true, - serverBuildId: 'old-build', - request: { runtimeIncarnation: 'exited-daemon' } - } - }) - expect(readdirSync(directory).sort()).toEqual(before) - }, 20_000) - - it('rejects mixed launch modes without starting a daemon', async () => { - const before = readdirSync(directory).sort() - const result = await runProcess({ - program, - args: [entry, '--detached', RELAY_RESET_PREPARATION_READ_FLAG], - cwd: directory, - input: JSON.stringify(envelope), - timeoutMs: 15_000, - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } - }) - expect(result.timedOut).toBe(false) - expect(result.code).toBe(1) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('arguments_invalid') - expect(readdirSync(directory).sort()).toEqual(before) - }, 20_000) -}) diff --git a/src/relay/relay-reset-preparation-reader.test.ts b/src/relay/relay-reset-preparation-reader.test.ts deleted file mode 100644 index 38b66e36b79..00000000000 --- a/src/relay/relay-reset-preparation-reader.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { PassThrough, Readable } from 'node:stream' -import { beforeEach, afterEach, expect, it, vi } from 'vitest' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { - isRelayResetPreparationReadMode, - readRelayResetPreparation, - readRelayResetPreparationStdin, - RELAY_RESET_PREPARATION_READ_FLAG -} from './relay-reset-preparation-reader' - -let directory: string -const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'private-owner-lease' -} -const journal = () => new RelayOwnerResetPreparationJournal(directory, '/socket', 'build') -const envelope = () => ({ - version: 1, - binding: journal().describe('owner', 'endpoint-credential'), - request -}) -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-read-')) -}) -afterEach(() => { - vi.useRealTimers() - rmSync(directory, { recursive: true, force: true }) -}) - -it('requires an exclusive reader mode and rejects combinations before normal mode selection', () => { - expect( - isRelayResetPreparationReadMode(['bun', 'relay.js', RELAY_RESET_PREPARATION_READ_FLAG]) - ).toBe(true) - expect(isRelayResetPreparationReadMode(['bun', 'relay.js', '--connect'])).toBe(false) - for (const extra of ['--connect', '--detached', '--orca-cli', '--sock-path']) { - expect(() => - isRelayResetPreparationReadMode(['bun', 'relay.js', extra, RELAY_RESET_PREPARATION_READ_FLAG]) - ).toThrow('arguments_invalid') - } -}) - -it('reads preparation after writer recreation without changing journal bytes', async () => { - journal().persist(request, 'owner', 'endpoint-credential', () => {}) - const file = join(directory, readdirSync(directory)[0]) - const before = readFileSync(file, 'utf8') - const output = await readRelayResetPreparationStdin(Readable.from([JSON.stringify(envelope())])) - expect(JSON.parse(output)).toEqual({ version: 1, preparation: journal().read(request) }) - expect(readFileSync(file, 'utf8')).toBe(before) - expect(readdirSync(directory)).toHaveLength(1) -}) - -it('reports missing evidence without creating a directory or inventing preparation', async () => { - const value = envelope() - value.binding = { ...value.binding, journalDirectory: join(directory, 'missing') } - expect(readRelayResetPreparation(value)).toEqual({ version: 1, preparation: null }) - expect(readdirSync(directory)).toEqual([]) -}) - -it('requires the exact recorded principal and owner lease', () => { - journal().persist(request, 'owner', 'endpoint-credential', () => {}) - const value = envelope() - expect(() => - readRelayResetPreparation({ ...value, binding: { ...value.binding, principal: 'other' } }) - ).toThrow('conflict') - expect(() => - readRelayResetPreparation({ ...value, request: { ...request, ownerLease: 'other' } }) - ).toThrow('conflict') -}) - -it('refuses relative journal paths rather than depending on the reader cwd', () => { - const value = envelope() - expect(() => - readRelayResetPreparation({ - ...value, - binding: { ...value.binding, journalDirectory: 'relative' } - }) - ).toThrow('directory_invalid') -}) - -it('bounds stdin and suppresses credential-bearing parse errors', async () => { - await expect( - readRelayResetPreparationStdin(Readable.from([Buffer.alloc(64 * 1024 + 1)])) - ).rejects.toThrow('read_failed') - await expect( - readRelayResetPreparationStdin(Readable.from(['private-owner-lease is invalid JSON'])) - ).rejects.toThrow(/^relay_reset_preparation_read_failed$/) -}) - -it('times out a reader whose input never ends', async () => { - vi.useFakeTimers() - const input = new PassThrough() - const result = readRelayResetPreparationStdin(input) - const rejection = expect(result).rejects.toThrow('read_failed') - await vi.advanceTimersByTimeAsync(10_000) - await rejection - expect(input.destroyed).toBe(true) - expect(vi.getTimerCount()).toBe(0) -}) diff --git a/src/relay/relay-reset-preparation-reader.ts b/src/relay/relay-reset-preparation-reader.ts deleted file mode 100644 index a738b9712cc..00000000000 --- a/src/relay/relay-reset-preparation-reader.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { isAbsolute } from 'node:path' -import type { Readable } from 'node:stream' -import { - parseRelayOwnerResetRequest, - readRelayResetRecord -} from '../shared/relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - RELAY_RESET_PREPARATION_READ_FLAG, - validateRelayResetPreparationRecord -} from '../shared/relay-reset-preparation-contract' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' - -export { RELAY_RESET_PREPARATION_READ_FLAG } from '../shared/relay-reset-preparation-contract' -const MAX_INPUT_BYTES = 64 * 1024 - -export function isRelayResetPreparationReadMode(argv: readonly string[]): boolean { - const args = argv.slice(2) - if (!args.includes(RELAY_RESET_PREPARATION_READ_FLAG)) { - return false - } - if (args.length !== 1 || args[0] !== RELAY_RESET_PREPARATION_READ_FLAG) { - throw new Error('relay_reset_preparation_read_arguments_invalid') - } - return true -} - -/** The invoking SSH account supplies OS read authority; no relay session or daemon is started. */ -export function readRelayResetPreparation(value: unknown) { - const envelope = readRelayResetRecord(value) - if (envelope?.version !== 1) { - throw new Error('relay_reset_preparation_read_invalid') - } - const binding = parseRelayResetPreparationBinding(envelope.binding) - const request = parseRelayOwnerResetRequest(envelope.request) - if (!isAbsolute(binding.journalDirectory)) { - throw new Error('relay_reset_preparation_read_directory_invalid') - } - const journal = new RelayOwnerResetPreparationJournal( - binding.journalDirectory, - binding.sockPath, - binding.serverBuildId - ) - const record = journal.read(request) - return { - version: 1 as const, - preparation: record ? validateRelayResetPreparationRecord(record, binding, request) : null - } -} - -export async function readRelayResetPreparationStdin(input: Readable): Promise { - const chunks: Buffer[] = [] - let bytes = 0 - const timeout = setTimeout( - () => input.destroy(new Error('relay_reset_preparation_read_timeout')), - 10_000 - ) - timeout.unref?.() - try { - for await (const value of input) { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) - bytes += chunk.length - if (bytes > MAX_INPUT_BYTES) { - throw new Error('relay_reset_preparation_read_too_large') - } - chunks.push(chunk) - } - const request = JSON.parse(Buffer.concat(chunks).toString('utf8')) - return `${JSON.stringify(readRelayResetPreparation(request))}\n` - } catch { - // Parse and filesystem errors can contain credentials from the request or journal. - throw new Error('relay_reset_preparation_read_failed') - } finally { - clearTimeout(timeout) - } -} diff --git a/src/relay/relay-runtime-owned-process-shutdown.test.ts b/src/relay/relay-runtime-owned-process-shutdown.test.ts deleted file mode 100644 index 15bf7c7742f..00000000000 --- a/src/relay/relay-runtime-owned-process-shutdown.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayRuntimeServices } from './relay-runtime-services' - -afterEach(() => vi.restoreAllMocks()) - -function fixture() { - const agents = vi.fn(async () => {}) - const skill = vi.fn(async () => {}) - const vault = vi.fn(async () => {}) - const responses = vi.fn(async () => {}) - const fileStreams = vi.fn(async () => {}) - const watchers = vi.fn(async () => {}) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: disposeOwnedProcesses only reads the owners stubbed here. - const runtime = Object.assign(Object.create(RelayRuntimeServices.prototype), { - agentExecHandler: { dispose: agents }, - skillInstallHandler: { dispose: skill }, - aiVaultService: { dispose: vault }, - responseStreams: { disposeAllAndWait: responses }, - fsHandler: { disposeFileStreams: fileStreams, disposeWatchers: watchers } - }) as RelayRuntimeServices - return { runtime, agents, skill, vault, responses, fileStreams, watchers } -} - -it.each(['agents', 'responses', 'fileStreams', 'watchers'] as const)( - 'does not acknowledge cleanup before %s have settled', - async (owner) => { - const f = fixture() - let release!: () => void - f[owner].mockReturnValue( - new Promise((resolve) => { - release = resolve - }) - ) - const finished = vi.fn() - const shutdown = f.runtime.disposeOwnedProcesses().then(finished) - expect(f[owner]).toHaveBeenCalledOnce() - await vi.waitFor(() => expect(f.vault).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - release() - await shutdown - expect(finished).toHaveBeenCalledOnce() - } -) - -it('rejects stream drain failures after attempting every owner and supports retry', async () => { - const f = fixture() - const responseError = new Error('response drain failed') - const fileError = new Error('file drain failed') - f.responses.mockRejectedValueOnce(responseError) - f.fileStreams.mockRejectedValueOnce(fileError) - await expect(f.runtime.disposeOwnedProcesses()).rejects.toMatchObject({ - message: 'relay_owned_process_shutdown_incomplete', - errors: [responseError, fileError] - }) - expect(f.skill).toHaveBeenCalledOnce() - expect(f.vault).toHaveBeenCalledOnce() - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() -}) - -it('keeps skill and AI Vault cleanup failures log-and-continue', async () => { - const f = fixture() - vi.spyOn(process.stderr, 'write').mockReturnValue(true) - f.skill.mockRejectedValueOnce(new Error('skill cleanup failed')) - f.vault.mockRejectedValueOnce(new Error('vault cleanup failed')) - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() - expect(f.responses).toHaveBeenCalledOnce() - expect(f.fileStreams).toHaveBeenCalledOnce() -}) - -it('handles hosts without a vault service', async () => { - const f = fixture() - Object.assign(f.runtime, { aiVaultService: null }) - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() - expect(f.vault).not.toHaveBeenCalled() -}) - -it('rejects a failed agent or watcher shutdown after cleaning every other owner', async () => { - const f = fixture() - const agentError = new Error('agent cleanup failed') - const watcherError = new Error('watcher cleanup failed') - f.agents.mockRejectedValueOnce(agentError) - f.watchers.mockRejectedValueOnce(watcherError) - await expect(f.runtime.disposeOwnedProcesses()).rejects.toMatchObject({ - message: 'relay_owned_process_shutdown_incomplete', - errors: [agentError, watcherError] - }) - expect(f.skill).toHaveBeenCalledOnce() - expect(f.vault).toHaveBeenCalledOnce() - expect(f.fileStreams).toHaveBeenCalledOnce() - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() -}) diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index e52fa6afddf..4295014782f 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -32,8 +32,6 @@ export class RelayRuntimeServices { readonly fsHandler: FsHandler readonly gitHandler: GitHandler readonly skillInstallHandler: SkillInstallHandler - readonly agentExecHandler: AgentExecHandler - private readonly responseStreams: GitResponseStreamRegistry private readonly aiVaultService: ReturnType | null private readonly sessionSearch: { dispose(): void } | null private readonly registeredHandlers: readonly unknown[] @@ -68,7 +66,6 @@ export class RelayRuntimeServices { // so two registries would hand out the same id, and only GitHandler routes the `git.responseAck` // credit every pump waits on. A second registry is not an option — see git-response-stream.ts. const responseStreams = new GitResponseStreamRegistry() - this.responseStreams = responseStreams this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) const watchRegistry = this.fsHandler.getWatchRegistry() this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) @@ -80,7 +77,7 @@ export class RelayRuntimeServices { this.skillInstallHandler = new SkillInstallHandler(dispatcher) const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) const portScanHandler = new PortScanHandler(dispatcher) - this.agentExecHandler = new AgentExecHandler(dispatcher) + const agentExecHandler = new AgentExecHandler(dispatcher) const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined @@ -106,7 +103,7 @@ export class RelayRuntimeServices { this.skillInstallHandler, externalAutomationsHandler, portScanHandler, - this.agentExecHandler, + agentExecHandler, workspaceSessionHandler, new AiVaultHandler(dispatcher, { hostPlatform, @@ -122,22 +119,7 @@ export class RelayRuntimeServices { this.registerRemoteCliRoutes() } - // Why: the handler work drain ends when a stream's metadata/sentinel is answered; the detached - // pumps and file descriptors behind it are only proven gone by these registry drains. async disposeOwnedProcesses(): Promise { - const failures: unknown[] = [] - const agents = this.agentExecHandler.dispose().catch((error: unknown) => { - failures.push(error) - }) - const responses = this.responseStreams.disposeAllAndWait().catch((error: unknown) => { - failures.push(error) - }) - const fileStreams = this.fsHandler.disposeFileStreams().catch((error: unknown) => { - failures.push(error) - }) - const watchers = this.fsHandler.disposeWatchers().catch((error: unknown) => { - failures.push(error) - }) await this.skillInstallHandler.dispose().catch((error) => { relayLogLine( `[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}` @@ -148,15 +130,6 @@ export class RelayRuntimeServices { `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` ) }) - await agents - await responses - await fileStreams - await watchers - // Why: an unclosed fd, watcher child or agent child defers shutdown so the next attempt retries - // it; skill/AI Vault cleanup stays log-and-continue until a later T2 slice. - if (failures.length > 0) { - throw new AggregateError(failures, 'relay_owned_process_shutdown_incomplete') - } } disposeHandlers(): void { diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts index 0ff1fe1a59e..e0093b9a166 100644 --- a/src/relay/relay-watcher-process-pool.ts +++ b/src/relay/relay-watcher-process-pool.ts @@ -5,8 +5,7 @@ import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-sup export type RelayWatcherProcessPool = Pick< RuntimeWatcherProcessPool, 'dispose' | 'forgetRoot' | 'subscribe' -> & - Partial> +> export function getRelayWatcherProcessEntryPath(): string { return join(__dirname, 'relay-watcher.js') diff --git a/src/relay/relay-watcher-setup-wait.ts b/src/relay/relay-watcher-setup-wait.ts index f09dfb7470b..a4c7b348b02 100644 --- a/src/relay/relay-watcher-setup-wait.ts +++ b/src/relay/relay-watcher-setup-wait.ts @@ -1,55 +1,5 @@ import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { RequestContext } from './dispatcher' -import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' - -export async function startInitialRelayWatch( - state: RelayWatcherTeardownState, - subscribe: () => Promise, - emitOverflow: () => void, - close: () => Promise -): Promise { - try { - await subscribe() - } catch (firstError) { - if (!state.closed && shouldRetryInitialRelayWatch(firstError)) { - try { - await subscribe() - emitOverflow() - return - } catch (quarantineError) { - void close().catch(() => {}) - throw quarantineError - } - } - void close().catch(() => {}) - throw firstError - } -} - -export async function awaitRelayWatcherSetupForClient( - state: RelayWatcherTeardownState, - context: RequestContext | undefined, - releaseClient: () => void -): Promise { - try { - await awaitRelayWatcherSetup(state.setupWaiters, context?.signal) - } catch (error) { - releaseClient() - const expectedAbort = - (error instanceof Error && error.name === 'AbortError') || - (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted') - if (expectedAbort) { - return - } - const message = error instanceof Error ? error.message : String(error) - process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`) - throw error - } - if (context?.isStale()) { - releaseClient() - } -} export function shouldRetryInitialRelayWatch(error: unknown): boolean { return ( diff --git a/src/relay/relay-watcher-shutdown.test.ts b/src/relay/relay-watcher-shutdown.test.ts deleted file mode 100644 index 9cf371969b2..00000000000 --- a/src/relay/relay-watcher-shutdown.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { RelayDispatcher } from './dispatcher' -import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' - -function fixture() { - const unsubscribe = vi.fn(async () => {}) - const pool = { - subscribe: vi.fn(async () => ({ unsubscribe })), - forgetRoot: vi.fn(), - dispose: vi.fn(), - disposeAndWait: vi.fn(async () => {}) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: shutdown paths never reach the dispatcher. - const registry = new RelayFilesystemWatchRegistry({} as RelayDispatcher, pool) - const root = join(tmpdir(), 'orca-watcher-shutdown-fixture') - return { registry, pool, unsubscribe, root } -} - -it('fences new watches synchronously and joins an in-flight native unsubscribe', async () => { - const f = fixture() - await f.registry.watch(f.root) - const pending = Promise.withResolvers() - f.unsubscribe.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - const duplicate = f.registry.closeWatchesAndWait() - await expect(f.registry.watch(f.root)).rejects.toThrow('relay_watcher_shutdown_fenced') - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - expect(f.unsubscribe).toHaveBeenCalledOnce() - expect(f.pool.dispose).not.toHaveBeenCalled() - pending.resolve() - await Promise.all([shutdown, duplicate]) - expect(f.pool.forgetRoot).toHaveBeenCalledWith(f.root) -}) - -it('retains failed teardown and retries without admitting replacement watches', async () => { - const f = fixture() - await f.registry.watch(f.root) - const failure = new Error('native close failed') - f.unsubscribe.mockRejectedValueOnce(failure) - await expect(f.registry.closeWatchesAndWait()).rejects.toMatchObject({ - message: 'relay_watcher_shutdown_incomplete', - errors: [failure] - }) - expect(f.pool.forgetRoot).not.toHaveBeenCalled() - await expect(f.registry.watch(f.root)).rejects.toThrow('relay_watcher_shutdown_fenced') - await f.registry.closeWatchesAndWait() - expect(f.unsubscribe).toHaveBeenCalledTimes(2) - expect(f.pool.forgetRoot).toHaveBeenCalledOnce() -}) - -it('waits for a late subscription and its unsubscribe after setup has begun', async () => { - const f = fixture() - const setup = Promise.withResolvers<{ unsubscribe: () => Promise }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only unsubscribe from a subscription. - f.pool.subscribe.mockReturnValue(setup.promise as ReturnType) - const watch = f.registry.watch(f.root) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - setup.resolve({ unsubscribe: f.unsubscribe }) - await vi.waitFor(() => expect(f.unsubscribe).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - close.resolve() - await Promise.all([watch, shutdown]) - expect(f.pool.subscribe).toHaveBeenCalledOnce() -}) - -it('joins teardown already started by client unwatch', async () => { - const f = fixture() - await f.registry.watch(f.root) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - f.registry.unwatch(f.root) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - close.resolve() - await shutdown - expect(f.unsubscribe).toHaveBeenCalledOnce() -}) - -it('retains a late subscription whose cleanup fails instead of treating it as failed setup', async () => { - const f = fixture() - const setup = Promise.withResolvers<{ unsubscribe: () => Promise }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only unsubscribe from a subscription. - f.pool.subscribe.mockReturnValue(setup.promise as ReturnType) - const failure = new Error('late native close failed') - f.unsubscribe.mockRejectedValueOnce(failure) - const watch = f.registry.watch(f.root).catch((error: unknown) => error) - const shutdown = f.registry.closeWatchesAndWait().catch((error: unknown) => error) - setup.resolve({ unsubscribe: f.unsubscribe }) - expect(await watch).toBe(failure) - expect(await shutdown).toMatchObject({ - message: 'relay_watcher_shutdown_incomplete', - errors: [failure] - }) - expect(f.pool.forgetRoot).not.toHaveBeenCalled() - await f.registry.closeWatchesAndWait() - expect(f.unsubscribe).toHaveBeenCalledTimes(2) - expect(f.pool.forgetRoot).toHaveBeenCalledOnce() -}) - -it('does not publish a replacement watch when its predecessor closes after shutdown fencing', async () => { - const f = fixture() - await f.registry.watch(f.root) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - f.registry.unwatch(f.root) - const replacement = f.registry.watch(f.root).catch((error: unknown) => error) - const shutdown = f.registry.closeWatchesAndWait() - close.resolve() - expect(await replacement).toMatchObject({ message: 'relay_watcher_shutdown_fenced' }) - await shutdown - expect(f.pool.subscribe).toHaveBeenCalledOnce() -}) - -it('full watcher disposal waits for physical pool exit after native unsubscribe completes', async () => { - const f = fixture() - await f.registry.watch(f.root) - const pending = Promise.withResolvers() - f.pool.disposeAndWait.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = f.registry.disposeAndWait().then(finished) - await vi.waitFor(() => expect(f.pool.forgetRoot).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - pending.resolve() - await shutdown -}) - -it('does not acknowledge a failed pool exit after native subscriptions close', async () => { - const f = fixture() - await f.registry.watch(f.root) - const error = new Error('pool exit unproven') - f.pool.disposeAndWait.mockRejectedValueOnce(error) - await expect(f.registry.disposeAndWait()).rejects.toMatchObject({ errors: [error] }) - await f.registry.disposeAndWait() -}) diff --git a/src/relay/relay-watcher-shutdown.ts b/src/relay/relay-watcher-shutdown.ts deleted file mode 100644 index bd0963adc52..00000000000 --- a/src/relay/relay-watcher-shutdown.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' -import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' -import type { - RelayWatcherTeardownState, - RelayWatcherTeardownTracker -} from './relay-watcher-teardown-tracker' - -export async function disposeRelayWatchesAndWait( - closeWatches: () => Promise, - pool: RelayWatcherProcessPool -): Promise { - const close = closeWatches() - const children = Promise.resolve().then(() => { - if (!pool.disposeAndWait) { - throw new Error('relay_watcher_pool_awaited_disposal_unavailable') - } - return pool.disposeAndWait() - }) - const results = await Promise.allSettled([close, children]) - const failures = results.filter((result) => result.status === 'rejected') - if (failures.length > 0) { - throw new AggregateError( - failures.map((failure) => failure.reason), - 'relay_watcher_shutdown_incomplete' - ) - } -} - -export async function closeRelayWatchesAndWait( - watches: ReadonlyMap, - pendingSetups: ReadonlyMap, - tracker: RelayWatcherTeardownTracker, - close: (state: RelayWatcherTeardownState) => Promise -): Promise { - const states = new Set(watches.values()) - for (const root of tracker.rootPaths()) { - const failed = tracker.failedState(root) - if (failed) { - states.add(failed) - } - } - const closures = Promise.allSettled([...states].map(close)) - // Setup refusal is expected after fencing; retained teardown failures remain authoritative. - await Promise.allSettled([...pendingSetups.values()].map((setup) => setup.promise)) - const results = await closures - // Why the fallback: join answers undefined for a root with nothing left in flight. - const remaining = await Promise.allSettled( - tracker.rootPaths().map((root) => tracker.join(root) ?? Promise.resolve()) - ) - const failures = [...results, ...remaining].filter((result) => result.status === 'rejected') - if (failures.length > 0) { - throw new AggregateError( - [...new Set(failures.map((failure) => failure.reason))], - 'relay_watcher_shutdown_incomplete' - ) - } -} diff --git a/src/relay/relay-watcher-teardown-tracker.ts b/src/relay/relay-watcher-teardown-tracker.ts index d9feff34b9f..785e4dd16ea 100644 --- a/src/relay/relay-watcher-teardown-tracker.ts +++ b/src/relay/relay-watcher-teardown-tracker.ts @@ -47,7 +47,7 @@ export class RelayWatcherTeardownTracker { }, (error) => { const physicalExit = isWatcherProcessFailure(error) ? error.physicalExit : undefined - if (!subscription && !state.subscription && !physicalExit) { + if (!subscription && !physicalExit) { this.failed.delete(state.rootKey) this.forgetRoot(state.rootPath) return diff --git a/src/relay/relay-work-admission.test.ts b/src/relay/relay-work-admission.test.ts deleted file mode 100644 index d5925953b11..00000000000 --- a/src/relay/relay-work-admission.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RequestContext } from './dispatcher-contract' -import { RelayWorkAdmission } from './relay-work-admission' -import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD } from '../shared/skill-ssh-relay-contract' - -function context(): RequestContext { - return { clientId: 1, isStale: () => false } -} - -function pendingOperation() { - let resolve!: () => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -async function nextTurn(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('RelayWorkAdmission', () => { - it('starts admitted operations synchronously and preserves their result', async () => { - const admission = new RelayWorkAdmission() - const operation = vi.fn(() => 'started') - const result = admission.run('fs.writeFile', context(), operation) - expect(operation).toHaveBeenCalledOnce() - await expect(result).resolves.toBe('started') - }) - - it('closes admission synchronously and drains existing work despite abort and disconnect', async () => { - const admission = new RelayWorkAdmission() - const controller = new AbortController() - let disconnected = false - const mutation = pendingOperation() - const running = admission.run( - 'fs.writeFile', - { ...context(), signal: controller.signal, isStale: () => disconnected }, - () => mutation.promise - ) - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - const rejectedOperation = vi.fn() - await expect(admission.run('fs.writeFile', context(), rejectedOperation)).rejects.toThrow( - 'relay_work_admission_closed' - ) - expect(rejectedOperation).not.toHaveBeenCalled() - controller.abort() - disconnected = true - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await running - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it.each([ - 'relay.status', - 'fs.unwatchAndWait', - 'agent.cancelExec', - SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD - ])('admits cleanup request %s while draining', async (method) => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('git.diff', context(), () => mutation.promise) - const drain = admission.beginDrain() - await expect( - admission.run(method, context(), () => { - mutation.resolve() - return 'acknowledged' - }) - ).resolves.toBe('acknowledged') - await running - await drain - }) - - it.each([ - 'rpc.cancel', - 'git.responseAck', - 'git.cancelResponseStream', - 'fs.streamAck', - 'fs.cancelStream', - 'fs.unwatch', - 'pty.ackData', - 'pty.setDeliveryPaused' - ])('admits cleanup notification %s while draining', async (method) => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('git.diff', context(), () => mutation.promise) - const drain = admission.beginDrain() - const acknowledge = vi.fn(() => mutation.resolve()) - admission.runNotification(method, context(), acknowledge) - expect(acknowledge).toHaveBeenCalledOnce() - await running - await drain - await expect(admission.run('git.diff', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('excludes the exact reset request without excluding another request from the same client', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('fs.writeFile', context(), () => mutation.promise) - const resetContext = context() - const drained = vi.fn() - const reset = admission.run('relay.reset', resetContext, async () => { - await admission.beginDrain(resetContext) - drained() - }) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await running - await reset - expect(drained).toHaveBeenCalledOnce() - }) - - it('rejects a forged context without closing admission', async () => { - const admission = new RelayWorkAdmission() - const actualContext = context() - await admission.run('relay.reset', actualContext, async () => { - await expect(admission.beginDrain({ ...actualContext })).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - await expect(admission.run('fs.writeFile', context(), () => 'open')).resolves.toBe('open') - }) - }) - - it('rejects an already-settled request context without closing admission', async () => { - const admission = new RelayWorkAdmission() - const oldContext = context() - await admission.run('relay.reset', oldContext, () => {}) - await expect(admission.beginDrain(oldContext)).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - await expect(admission.run('fs.writeFile', context(), () => 'open')).resolves.toBe('open') - }) - - it('waits for reset work when the host lifecycle supplies no exclusion', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const reset = admission.run('relay.reset', context(), () => mutation.promise) - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await reset - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it('drops new mutation notifications without invoking their handlers during drain', async () => { - const admission = new RelayWorkAdmission() - await admission.beginDrain() - const mutate = vi.fn() - admission.runNotification('pty.write', context(), mutate) - expect(mutate).not.toHaveBeenCalled() - await expect(admission.run('pty.ackData', context(), mutate)).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('tracks asynchronous notifications through settlement while draining', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const operation = vi.fn(async () => mutation.promise) - admission.runNotification('pty.write', context(), operation) - expect(operation).toHaveBeenCalledOnce() - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it('releases tracking after synchronous notification failure', async () => { - const admission = new RelayWorkAdmission() - expect(() => - admission.runNotification('pty.write', context(), () => { - throw new Error('notification_failed') - }) - ).toThrow('notification_failed') - await admission.beginDrain() - const mutate = vi.fn() - admission.runNotification('pty.write', context(), mutate) - expect(mutate).not.toHaveBeenCalled() - }) - - it('settles concurrent drains after an admitted operation fails without reopening', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('fs.writeFile', context(), async () => { - await mutation.promise - throw new Error('write_failed') - }) - const failure = expect(running).rejects.toThrow('write_failed') - const firstDrain = admission.beginDrain() - const secondDrain = admission.beginDrain() - mutation.resolve() - await failure - await Promise.all([firstDrain, secondDrain]) - await expect(admission.run('fs.writeFile', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('turns synchronous operation failures into rejected promises and releases tracking', async () => { - const admission = new RelayWorkAdmission() - const result = admission.run('fs.writeFile', context(), () => { - throw new Error('write_failed') - }) - await expect(result).rejects.toThrow('write_failed') - await admission.beginDrain() - await expect(admission.run('fs.writeFile', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) -}) diff --git a/src/relay/relay-work-admission.ts b/src/relay/relay-work-admission.ts deleted file mode 100644 index 2bf2ecdab4e..00000000000 --- a/src/relay/relay-work-admission.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { RequestContext } from './dispatcher-contract' -import { allowsRelayWorkDuringDrain } from '../shared/relay-work-drain-contract' - -/** Handler drain is not proof of physical process exit or detached producer cleanup. */ -export class RelayWorkAdmission { - private draining = false - private readonly active = new Map }>() - - allows(method: string, notification = false): boolean { - return !this.draining || allowsRelayWorkDuringDrain(method, notification) - } - - // Why not async: an async wrapper adds microtask turns to every relay handler's response. - run(method: string, context: RequestContext, operation: () => T | Promise): Promise { - if (!this.allows(method)) { - return Promise.reject(new Error('relay_work_admission_closed')) - } - const finish = this.trackEntry(context) - let result: Promise - try { - const value = operation() - result = value instanceof Promise ? value : Promise.resolve(value) - } catch (error) { - finish() - return Promise.reject(error) - } - result.then(finish, finish) - return result - } - - runNotification(method: string, context: RequestContext, operation: () => void): void { - if (!this.allows(method, true)) { - return - } - const finish = this.trackEntry(context) - try { - const result = operation() - void Promise.resolve(result).then(finish, (error) => { - finish() - process.stderr.write(`[relay] Notification handler failed: ${String(error)}\n`) - }) - } catch (error) { - finish() - throw error - } - } - - assertActiveContext(context: RequestContext): void { - if (![...this.active.values()].some((entry) => entry.context === context)) { - throw new Error('relay_work_drain_context_not_active') - } - } - - /** Only the actual initiating request context may be excluded from its own drain. */ - async beginDrain(exclude?: RequestContext): Promise { - if (exclude) { - this.assertActiveContext(exclude) - } - this.draining = true - for (;;) { - const pending = [...this.active.values()].filter((entry) => entry.context !== exclude) - if (pending.length === 0) { - return - } - await Promise.all(pending.map((entry) => entry.done)) - } - } - - // Why: no Promise.withResolvers — the legacy relay bundle still targets Node 18 hosts. - private trackEntry(context: RequestContext): () => void { - const key = {} - let resolve!: () => void - const done = new Promise((settle) => { - resolve = settle - }) - this.active.set(key, { context, done }) - return () => { - this.active.delete(key) - resolve() - } - } -} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 1ed674057f1..e986a19b709 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -11,10 +11,6 @@ import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' import { runRelayRuntimeSelfTestCommand } from './relay-runtime-self-test' import { runRelayWindowsBreakawayLaunchIfRequested } from './relay-windows-breakaway-launch' import { RELAY_RUNTIME_SELF_TEST_FLAG } from '../shared/relay-runtime-self-test-report' -import { - isRelayResetPreparationReadMode, - readRelayResetPreparationStdin -} from './relay-reset-preparation-reader' async function main(): Promise { const selfTestFlag = process.argv.indexOf(RELAY_RUNTIME_SELF_TEST_FLAG) @@ -25,11 +21,6 @@ async function main(): Promise { if (runRelayWindowsBreakawayLaunchIfRequested(process.argv)) { return } - // A read-only exec: the SSH account's own file access is the authority; no daemon starts. - if (isRelayResetPreparationReadMode(process.argv)) { - process.stdout.write(await readRelayResetPreparationStdin(process.stdin)) - return - } const options = parseRelayLaunchOptions(process.argv) if (options.connectMode) { runRelayConnectChannel(options.sockPath, readRelayEndpointCredential(options.credentialFile)) diff --git a/src/relay/ssh-pty-consumer-resume-client.test.ts b/src/relay/ssh-pty-consumer-resume-client.test.ts deleted file mode 100644 index d2b015f9214..00000000000 --- a/src/relay/ssh-pty-consumer-resume-client.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' -import { encodeJsonRpcFrame } from './protocol' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function frame(id: number, method: string, overrides: Record = {}): Buffer { - return encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id, - method, - params: { - protocolVersion: 1, - clientInstanceId: 'stable-client', - requestedRole: 'session-owner', - ...overrides - } - }, - id, - 0 - ) -} - -function response(buffer: Buffer) { - return JSON.parse(buffer.subarray(13, 13 + buffer.readUInt32BE(9)).toString('utf8')) -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('pty.resumeClient through RelayDispatcher', () => { - let dispatcher: RelayDispatcher | undefined - afterEach(() => dispatcher?.dispose()) - - function fixture() { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settled) => { - writes.push(Buffer.from(data)) - settled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - return { dispatcher, adapter, writes } - } - - it('refuses absent historical ownership without minting a fresh owner or reserving the connection', async () => { - const { dispatcher, adapter, writes } = fixture() - const resume = { ownerGeneration: 1, ownerLease: 'forgotten-lease' } - dispatcher.feed(frame(1, 'pty.resumeClient', { resume })) - await flushRequests() - expect(response(writes[0]).error.message).toContain('pty_consumer_resume_owner_missing') - expect(adapter.activeSessionOwner(1)).toBeNull() - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - dispatcher.feed(frame(2, 'pty.openClient', { resume })) - await flushRequests() - expect(response(writes[1]).result).toMatchObject({ - role: 'session-owner', - clientGeneration: 1, - ownerGeneration: 1, - resumed: false - }) - expect(adapter.activeSessionOwner(1)).not.toBeNull() - }) - - it.each([true, false])( - 'settles exact-owner resume authority only on response publication (success=%s)', - async (ok) => { - const { dispatcher, adapter, writes } = fixture() - dispatcher.feed(frame(1, 'pty.openClient')) - await flushRequests() - const grant = response(writes[0]).result - const incumbent = adapter.activeSessionOwner(1) - expect(incumbent).not.toBeNull() - const resumedWrites: Buffer[] = [] - const settlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] - const successor = dispatcher.attachClient( - (data, settled) => { - resumedWrites.push(Buffer.from(data)) - settlements.push(settled) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const release = vi.spyOn(dispatcher, 'releaseDisplacedClient') - dispatcher.feedClient( - successor, - frame(2, 'pty.resumeClient', { - resume: { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease } - }) - ) - await flushRequests() - expect(response(resumedWrites[0]).result).toMatchObject({ - role: 'session-owner', - resumed: true, - ownerGeneration: 2, - ownerLease: grant.ownerLease - }) - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(successor)).toBeNull() - expect(release).not.toHaveBeenCalled() - expect(() => adapter.assertOwnerPublicationSettled()).toThrow( - 'pty_consumer_owner_publication_pending' - ) - settlements[0](ok ? { ok: true } : { ok: false, error: new Error('response failed') }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - if (ok) { - expect(adapter.activeSessionOwner(1)).toBeNull() - expect(adapter.activeSessionOwner(successor)).not.toBeNull() - expect(release).toHaveBeenCalledTimes(1) - } else { - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(successor)).toBeNull() - expect(release).not.toHaveBeenCalled() - } - release.mockRestore() - } - ) -}) diff --git a/src/relay/ssh-pty-consumer-session-adapter.test.ts b/src/relay/ssh-pty-consumer-session-adapter.test.ts index 262fe76a82a..7c38799b876 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.test.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.test.ts @@ -61,60 +61,6 @@ describe('SshPtyConsumerSessionAdapter', () => { vi.useRealTimers() }) - it('rolls back replacement ownership when response callback registration throws', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const registration = vi.spyOn(dispatcher, 'onRequest') - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - const openClient = registration.mock.calls.find(([method]) => method === 'pty.openClient')![1] - registration.mockRestore() - dispatcher.feed(openFrame(1)) - await flushRequests() - const grant = responseResult(writes[0]) - const incumbent = adapter.activeSessionOwner(1) - expect(incumbent).not.toBeNull() - const replacement = dispatcher.attachClient(() => true, {}, endpointIdentity) - const closeIncumbent = vi.spyOn(dispatcher, 'releaseDisplacedClient') - const params = { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease } - } - await expect( - openClient(params, { - clientId: replacement, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: () => { - throw new Error('registration failed') - } - }) - ).rejects.toThrow('registration failed') - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(replacement)).toBeNull() - expect(closeIncumbent).not.toHaveBeenCalled() - await openClient(params, { - clientId: replacement, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (settle) => settle({ ok: false, error: new Error('cancel retry') }) - }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(closeIncumbent).not.toHaveBeenCalled() - closeIncumbent.mockRestore() - }) - it('does not activate owner authority until the grant write settles', async () => { const firstWrites: Buffer[] = [] const firstSettlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] @@ -127,13 +73,10 @@ describe('SshPtyConsumerSessionAdapter', () => { { supportsWriteCallback: true }, endpointIdentity ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') dispatcher.feed(openFrame(1)) await flushRequests() - expect(() => adapter.assertOwnerPublicationSettled()).toThrow( - 'pty_consumer_owner_publication_pending' - ) const secondWrites: Buffer[] = [] const secondId = dispatcher.attachClient( @@ -159,7 +102,6 @@ describe('SshPtyConsumerSessionAdapter', () => { code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) firstSettlements[0]({ ok: true }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() }) it('rolls back owner election when the grant write fails', async () => { @@ -171,10 +113,9 @@ describe('SshPtyConsumerSessionAdapter', () => { { supportsWriteCallback: true }, endpointIdentity ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') dispatcher.feed(openFrame(1)) await flushRequests() - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() const retryWrites: Buffer[] = [] const retryId = dispatcher.attachClient( diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index e7b26689723..906edd47ff8 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -1,6 +1,5 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - PTY_CONSUMER_RESUME_CLIENT_METHOD, PtyConsumerSession, type PtyConsumerSessionAdmission, type PtyConsumerSessionGrant @@ -28,7 +27,6 @@ export class SshPtyConsumerSessionAdapter { private readonly session: PtyConsumerSession private readonly sourceCredit: SshPtySourceCreditAdapter private readonly pausedDeliveryByPty = new Map() - private readonly pendingPublications = new Set() constructor( private readonly dispatcher: RelayDispatcher, @@ -61,9 +59,6 @@ export class SshPtyConsumerSessionAdapter { dispatcher.onRequest(SSH_PTY_OPEN_CLIENT_METHOD, (params, context) => this.openClient(params, context) ) - dispatcher.onRequest(PTY_CONSUMER_RESUME_CLIENT_METHOD, (params, context) => - this.openClient(params, context, true) - ) dispatcher.onClientDetached((clientId, cause) => { const connectionKey = String(clientId) const grant = this.session.activeGrant(connectionKey) @@ -219,35 +214,9 @@ export class SshPtyConsumerSessionAdapter { return sshPtyDeliveryMode(this.session.activeGrant(String(clientId))) } - activeSessionOwner( - clientId: number - ): Readonly<{ ownerGeneration: number; ownerLease: string }> | null { - const grant = this.session.activeGrant(String(clientId)) - const ownerGeneration = grant?.ownerGeneration - if ( - grant?.role !== 'session-owner' || - typeof ownerGeneration !== 'number' || - !Number.isSafeInteger(ownerGeneration) || - !grant.ownerLease - ) { - return null - } - return Object.freeze({ - ownerGeneration, - ownerLease: grant.ownerLease - }) - } - - assertOwnerPublicationSettled(): void { - if (this.pendingPublications.size > 0) { - throw new Error('pty_consumer_owner_publication_pending') - } - } - private async openClient( rawParams: Record, - context: RequestContext, - resumeOnly = false + context: RequestContext ): Promise { const params = parseOpenClientParams(rawParams) if (params.protocolVersion !== PTY_CONSUMER_SESSION_PROTOCOL_VERSION) { @@ -256,38 +225,24 @@ export class SshPtyConsumerSessionAdapter { ) } const identity = requireIdentity(context) - const authenticate = { + const admission = this.session.admit(params, { connectionId: String(context.clientId), principal: identity.principal, authenticated: identity.authenticated, allowSessionOwner: identity.allowSessionOwner - } - const admission = resumeOnly - ? this.session.admitResumed(params, authenticate) - : this.session.admit(params, authenticate) + }) if (!context.onResponseSettled) { admission.rollbackPublication() throw new Error('SSH PTY consumer response publication fence is unavailable') } - this.pendingPublications.add(admission) - try { - context.onResponseSettled((result) => { - try { - if (!result.ok) { - admission.rollbackPublication() - return - } - admission.commitPublication() - this.closeDisplacedOwner(admission.displacedOwner) - } finally { - this.pendingPublications.delete(admission) - } - }) - } catch (error) { - this.pendingPublications.delete(admission) - admission.rollbackPublication() - throw error - } + context.onResponseSettled((result) => { + if (!result.ok) { + admission.rollbackPublication() + return + } + admission.commitPublication() + this.closeDisplacedOwner(admission.displacedOwner) + }) return admission.grant } diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 6758c058b58..4d1e9987a09 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -125,11 +125,11 @@ export function useAppSessionPersistence(): void { store: useAppStore, shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed, - onPersistError: (error) => console.warn('[session] Local session patch failed:', error), persist: ({ patch }) => { const state = useAppStore.getState() // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) + void localWrite const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId if (uploadAuthorities.length > 0) { @@ -193,7 +193,6 @@ export function useAppSessionPersistence(): void { } })() } - return localWrite } }) }, []) diff --git a/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts b/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts deleted file mode 100644 index ccc239a97be..00000000000 --- a/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useAppStore, type AppState } from '@/store' -import { - createSessionWriteSubscriber, - type WorkspaceSessionWrite -} from './session-write-subscriber' - -function acknowledgment() { - let resolve!: () => void - let reject!: (error: unknown) => void - const promise = new Promise((yes, no) => { - resolve = yes - reject = no - }) - return { promise, resolve, reject } -} - -describe('session write acknowledgments', () => { - let initial: AppState - let dispose: (() => void) | undefined - beforeEach(() => { - initial = useAppStore.getState() - vi.useFakeTimers() - }) - afterEach(() => { - dispose?.() - dispose = undefined - vi.useRealTimers() - useAppStore.setState(initial, true) - }) - - function setup() { - const first = acknowledgment() - const persist = vi - .fn<(write: WorkspaceSessionWrite) => void | Promise>() - .mockImplementationOnce(() => first.promise) - const onPersistError = vi.fn() - let wake: (() => void) | undefined - dispose = createSessionWriteSubscriber({ - store: useAppStore, - persist, - onPersistError, - shouldSchedulePersist: () => true, - subscribeToPersistGateOpen: (listener) => { - wake = listener - return () => { - wake = undefined - } - } - }) - useAppStore.setState({ - workspaceSessionReady: true, - hydrationSucceeded: true, - activeTabId: 'old' - }) - vi.advanceTimersByTime(200) - return { first, persist, onPersistError, wake: () => wake?.() } - } - - it('serializes writes and preserves a newer edit to the in-flight field', async () => { - const { first, persist } = setup() - useAppStore.setState({ activeTabId: 'new' }) - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(1) - first.resolve() - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('new') - }) - - it.each(['gate', 'store'] as const)( - 'retains rejected intent until a %s wake and rebuilds fresh state', - async (source) => { - const { first, persist, onPersistError, wake } = setup() - useAppStore.setState({ activeTabId: 'new' }) - first.reject(new Error('retirement publication deferred')) - await vi.advanceTimersByTimeAsync(10_000) - expect(persist).toHaveBeenCalledTimes(1) - expect(onPersistError).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - if (source === 'gate') { - wake() - } else { - useAppStore.getState().setCacheTimerStartedAt('tab:pane', Date.now()) - } - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('new') - expect(persist.mock.calls[1][0].patch).toHaveProperty('activeRepoId') - } - ) - - it.each(['resolve', 'reject'] as const)( - 'does not resume after disposal and late %s', - async (outcome) => { - const { first, persist, onPersistError, wake } = setup() - useAppStore.setState({ activeTabId: 'new' }) - dispose?.() - if (outcome === 'resolve') { - first.resolve() - } else { - first.reject(new Error('late failure')) - } - wake() - await vi.advanceTimersByTimeAsync(10_000) - expect(persist).toHaveBeenCalledTimes(1) - expect(onPersistError).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - } - ) - - it('retains a synchronous throw for a later wake', async () => { - const persist = vi.fn<(write: WorkspaceSessionWrite) => void>().mockImplementationOnce(() => { - throw new Error('write refused') - }) - dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) - useAppStore.setState({ - workspaceSessionReady: true, - hydrationSucceeded: true, - activeTabId: 'retained' - }) - await vi.advanceTimersByTimeAsync(200) - expect(vi.getTimerCount()).toBe(0) - useAppStore.getState().setCacheTimerStartedAt('tab:pane', Date.now()) - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('retained') - }) -}) diff --git a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts index 2532002f9e7..3f2c5ce284c 100644 --- a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts +++ b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts @@ -50,9 +50,7 @@ function createHarness() { }, getState: () => state }, - persist: (payload) => { - persisted.push(payload) - } + persist: (payload) => persisted.push(payload) }) return { dispose, @@ -176,9 +174,7 @@ describe('session write subscriber allocation', () => { }, getState: () => state }, - persist: (payload) => { - persisted.push(payload) - }, + persist: (payload) => persisted.push(payload), shouldSchedulePersist: () => gateOpen, subscribeToPersistGateOpen: () => () => {} }) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 581d9e875c6..7e7e9cf7360 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -101,8 +101,7 @@ export type SessionWriteSubscriberDeps = { subscribe: (listener: (state: AppState) => void) => () => void getState: () => AppState } - persist: (payload: WorkspaceSessionWrite) => void | Promise - onPersistError?: (error: unknown) => void + persist: (payload: WorkspaceSessionWrite) => void debounceMs?: number } & SessionWritePersistGate @@ -115,14 +114,11 @@ export type SessionWriteSubscriberDeps = { export function createSessionWriteSubscriber({ store, persist, - onPersistError, shouldSchedulePersist, subscribeToPersistGateOpen, debounceMs = 150 }: SessionWriteSubscriberDeps): () => void { let timer: ReturnType | null = null - let disposed = false - let writing = false // Why: the subscriber fires on every store update (agent status, usage // refreshes, runtime title ticks, …). Without this gate each fire reset // the debounce, and when it finally expired buildWorkspaceSessionPayload @@ -137,17 +133,14 @@ export function createSessionWriteSubscriber({ let prevUnifiedTabsSource: UnifiedTabsByWorktree | null = null // Why: this set is the only record that a mutation still owes a write — `prev` has already // advanced past it, and change detection is identity-based, so a field dropped from here can - // never be re-detected. In-flight fields remain owned by that write until acknowledgment; - // failures merge them back without overwriting newer same-field edits. + // never be re-detected. It is retired only by a flush that reached `persist` (or found nothing + // left to write), and by unsubscribe. A closed gate never retires it. const pendingChangedFields = new Set() const terminalTabsProjection = createTerminalSessionTabsProjection() const unifiedTabsProjection = createUnifiedSessionTabsProjection() const flushPendingWrite = (): void => { timer = null - if (disposed || writing || pendingChangedFields.size === 0) { - return - } // Why: rebuild from the freshest store state rather than the snapshot // captured when this timer was scheduled. Today this is equivalent // because buildWorkspaceSessionPayload reads only SESSION_RELEVANT_FIELDS @@ -172,41 +165,10 @@ export function createSessionWriteSubscriber({ if (Object.keys(patch).length === 0) { return } - writing = true - const settle = (failed: boolean, error?: unknown): void => { - writing = false - if (disposed) { - return - } - if (failed) { - for (const field of changed) { - pendingChangedFields.add(field) - } - // A rejection waits for the next store/gate wake, not a retry loop. - onPersistError?.(error) - } else if (pendingChangedFields.size > 0) { - armFlushTimer() - } - } - try { - const result = persist({ patch }) - if (result && typeof result.then === 'function') { - void result.then( - () => settle(false), - (error: unknown) => settle(true, error) - ) - } else { - settle(false) - } - } catch (error) { - settle(true, error) - } + persist({ patch }) } const armFlushTimer = (): void => { - if (disposed || writing) { - return - } if (timer !== null) { clearTimeout(timer) } @@ -310,7 +272,6 @@ export function createSessionWriteSubscriber({ }) return () => { - disposed = true unsub() unsubGateOpen?.() if (timer !== null) { diff --git a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts deleted file mode 100644 index 345a0db60d4..00000000000 --- a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { collectLeafIds } from '../components/terminal-pane/terminal-pane-layout-tree' -import { - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, - trackRetiredLeafIds -} from '../components/terminal-pane/terminal-live-layout-reconciliation' -import { applyFreshWebSessionTabsSnapshot } from './web-session-tabs-sync' -import { - clearWebSessionTerminalOrphanRecoveryForTests, - recoverWebSessionTerminalOrphansBeforeApply -} from './web-session-terminal-orphan-recovery' -import { - ENV, - LEAF_ID, - SECOND_LEAF_ID, - makeSnapshot, - makeState, - resetWebSessionTabsSyncTestState -} from './web-session-tabs-sync-test-harness' - -vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) -vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ - observeAgentHookCompletionForNotification: vi.fn() -})) - -const TAB_ID = 'web-terminal-host-tab-1' -const mountedLeaves = [LEAF_ID, SECOND_LEAF_ID] - -function snapshot(version: number, leaves = mountedLeaves): RuntimeMobileSessionTabsResult { - return makeSnapshot( - leaves.map((leafId) => ({ - type: 'terminal' as const, - id: `host-tab-1::${leafId}`, - parentTabId: 'host-tab-1', - leafId, - title: 'shell', - isActive: leafId === LEAF_ID, - status: 'ready' as const, - terminal: `terminal-${leafId}` - })), - { snapshotVersion: version } - ) -} - -function retiredSnapshot(): RuntimeMobileSessionTabsResult { - return { - ...snapshot(3, [LEAF_ID]), - retiredTerminalSurfaces: [ - { - parentTabId: 'host-tab-1', - leafId: SECOND_LEAF_ID, - terminal: `terminal-${SECOND_LEAF_ID}`, - ptyId: 'native-second', - incarnationId: 'inc-second' - } - ] - } -} - -function createReconciliation() { - let state = makeState() - let previousLayoutLeafIds: ReadonlySet = new Set() - let retiredLeafIds: ReadonlySet = new Set() - const mounted = new Set(mountedLeaves) - const call = vi.fn(async () => { - throw new Error('execution host unavailable') - }) - - function plan(secondPtyId: string | null): number[] { - const root = state.terminalLayoutsByTabId[TAB_ID]?.root - expect(root).toBeDefined() - const layoutLeafIds = new Set(root ? collectLeafIds(root) : []) - retiredLeafIds = trackRetiredLeafIds({ - retiredLeafIds, - previousLayoutLeafIds, - layoutLeafIds, - mountedLeafIds: mounted - }) - previousLayoutLeafIds = layoutLeafIds - return selectRetiredPaneIds(planTerminalLiveLayoutRemovals(root, mounted, retiredLeafIds), { - paneCount: mounted.size, - paneIdForLeaf: (leaf) => (leaf === LEAF_ID ? 1 : 2), - ptyIdForPane: (pane) => (pane === 1 ? 'remote:first' : secondPtyId) - }) - } - - return { - call, - plan, - removeSecond: () => mounted.delete(SECOND_LEAF_ID), - leaves: () => collectLeafIds(state.terminalLayoutsByTabId[TAB_ID].root!), - async receive(incoming: RuntimeMobileSessionTabsResult) { - const recovered = await recoverWebSessionTerminalOrphansBeforeApply(state, incoming, ENV, { - call - }) - expect(recovered).not.toBeNull() - if (recovered) { - state = { ...state, ...applyFreshWebSessionTabsSnapshot(state, recovered, ENV) } - } - } - } -} - -describe('host snapshot fences before split-pane retirement', () => { - beforeEach(() => { - resetWebSessionTabsSyncTestState() - clearWebSessionTerminalOrphanRecoveryForTests() - }) - - it('keeps a null-transport pane when an older layout arrives', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - expect(view.plan('remote:second')).toEqual([]) - await view.receive(retiredSnapshotWithVersion(1)) - expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) - }) - - it('retains a missing pane when a newer layout cannot verify the host inventory', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(snapshot(3, [LEAF_ID])) - expect(view.call).toHaveBeenCalled() - expect(view.leaves()).toContain(SECOND_LEAF_ID) - expect(view.plan(null)).toEqual([]) - }) - - it('defers proven retirement until the transport clears and does not close twice', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(retiredSnapshot()) - expect(view.leaves()).toEqual([LEAF_ID]) - expect(view.plan('remote:second')).toEqual([]) - expect(view.plan(null)).toEqual([2]) - view.removeSecond() - expect(view.plan(null)).toEqual([]) - }) - - it('clears deferred retirement when the host reintroduces the leaf before detach', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(retiredSnapshot()) - expect(view.plan('remote:second')).toEqual([]) - await view.receive(snapshot(4)) - expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) - }) -}) - -function retiredSnapshotWithVersion(snapshotVersion: number): RuntimeMobileSessionTabsResult { - return { ...retiredSnapshot(), snapshotVersion } -} diff --git a/src/shared/managed-orcad-ssh-owner.test.ts b/src/shared/managed-orcad-ssh-owner.test.ts deleted file mode 100644 index 5d410d81acc..00000000000 --- a/src/shared/managed-orcad-ssh-owner.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId, - isEphemeralRuntimeSshOwner -} from './managed-orcad-ssh-owner' - -describe('managed orcad SSH ownership', () => { - it('writes a marker that older clients keep hidden as an internal target', () => { - const owner = createManagedOrcadSshOwner('environment-1') - - expect(owner).toEqual({ - type: 'on-demand-runtime', - runtimeId: 'managed-orcad:environment-1' - }) - expect(getManagedOrcadOwnerEnvironmentId(owner)).toBe('environment-1') - expect(isEphemeralRuntimeSshOwner(owner)).toBe(false) - }) - - it('keeps ordinary on-demand runtime targets ephemeral', () => { - const owner = { type: 'on-demand-runtime' as const, runtimeId: 'runtime-1' } - - expect(getManagedOrcadOwnerEnvironmentId(owner)).toBeNull() - expect(isEphemeralRuntimeSshOwner(owner)).toBe(true) - }) -}) diff --git a/src/shared/managed-orcad-ssh-owner.ts b/src/shared/managed-orcad-ssh-owner.ts deleted file mode 100644 index f3c554d715b..00000000000 --- a/src/shared/managed-orcad-ssh-owner.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { SshTarget } from './ssh-types' - -// Why on-demand-runtime: shipped builds already hide targets with that owner, so a downgrade keeps it fenced. -const MANAGED_ORCAD_RUNTIME_ID_PREFIX = 'managed-orcad:' - -type SshTargetOwner = NonNullable - -export function createManagedOrcadSshOwner(environmentId: string): SshTargetOwner { - return { - type: 'on-demand-runtime', - runtimeId: `${MANAGED_ORCAD_RUNTIME_ID_PREFIX}${environmentId}` - } -} - -export function getManagedOrcadOwnerEnvironmentId( - owner: SshTargetOwner | undefined -): string | null { - if (!owner) { - return null - } - const environmentId = owner.runtimeId.startsWith(MANAGED_ORCAD_RUNTIME_ID_PREFIX) - ? owner.runtimeId.slice(MANAGED_ORCAD_RUNTIME_ID_PREFIX.length) - : '' - return environmentId || null -} - -export function isEphemeralRuntimeSshOwner(owner: SshTargetOwner | undefined): boolean { - return owner?.type === 'on-demand-runtime' && getManagedOrcadOwnerEnvironmentId(owner) === null -} diff --git a/src/shared/orcad-decommission.ts b/src/shared/orcad-decommission.ts deleted file mode 100644 index 9d1c9c3f789..00000000000 --- a/src/shared/orcad-decommission.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** What a client learns from decommissioning a managed orcad. */ -import { z } from 'zod' - -export const OrcadDecommissionResultSchema = z.discriminatedUnion('outcome', [ - z.object({ - outcome: z.literal('decommissioned'), - version: z.string().min(1).max(255), - /** The daemon's fate: `retired`, or kept with its terminals (`live`/`unverifiable`). */ - retirement: z.enum(['retired', 'live', 'unverifiable']) - }), - z.object({ - outcome: z.literal('refused'), - verdict: z.enum(['live', 'unverifiable']), - code: z.string(), - reason: z.string() - }) -]) - -export type OrcadDecommissionResult = z.infer diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts deleted file mode 100644 index 85a13fb2dde..00000000000 --- a/src/shared/orcad-managed-runtime.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { PublicKnownRuntimeEnvironment } from './runtime-environments' -import type { OrcadTerminalCensus } from './orcad-terminal-census' - -export const ORCAD_MANAGED_REMOTE_PORT = 6_768 - -export type OrcadManagedDeferral = { - outcome: 'deferred' - candidateVersion: string - code: string - reason: string - /** False when force cannot make the rejected lifecycle transition safe. */ - forceable?: boolean -} - -export type OrcadManagedDeployResult = - | { - outcome: 'created' | 'updated' | 'already-current' - environment: PublicKnownRuntimeEnvironment - activeVersion: string - } - | OrcadManagedDeferral - -export type OrcadManagedRollbackResult = - | { - outcome: 'rolled-back' - environment: PublicKnownRuntimeEnvironment - activeVersion: string - discarded: string[] - } - | { outcome: 'refused' | 'failed'; code: string; reason: string } - -export type OrcadManagedRecoveryResult = - | { outcome: 'none' } - | { outcome: 'pending'; code: string; reason: string } - | { - outcome: 'recovered' - resolution: 'committed' | 'restored-incumbent' - activeVersion: string | null - environment: PublicKnownRuntimeEnvironment - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -/** Only a proven `exited` unlinks the server locally; anything less keeps it linked. */ -export type OrcadManagedStopResult = - | { - outcome: 'unlinked' - verdict: 'exited' - environmentId: string - sshTargetId: string - stoppedVersion: string | null - retirement: 'retired' | 'live' | 'unverifiable' | null - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export type OrcadManagedCancelStopResult = - | { outcome: 'none' } - /** The stop was withdrawn before orcad acted on it; the server keeps serving. */ - | { outcome: 'canceled'; activeVersion: string } - /** orcad had already exited; finish with stop to unlink the server. */ - | { outcome: 'already-stopped' } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export type OrcadManagedRuntimeStatus = { - environmentId: string - sshTargetId: string - activeVersion: string | null - previousVersion: string | null - activatedAt: string | null - rollbackAvailable: boolean - recovery: - | { - operation: 'activate' - phase: 'prepared' | 'incumbent-stopped' | 'snapshot-captured' | 'candidate-ready' - version: string - startedAt: string - } - | { - operation: 'rollback' - phase: - | 'prepared' - | 'incumbent-stopped' - | 'rescue-captured' - | 'rollback-state-restored' - | 'target-ready' - version: string - startedAt: string - } - | { - operation: 'decommission' - phase: 'prepared' | 'stop-dispatched' | 'process-exited' - version: string - startedAt: string - } - | null - /** Terminals the daemon runs; `null` counts are unverifiable and block updates and stops. */ - terminals: OrcadTerminalCensus - /** The last update this client deferred for this server, cleared once one goes through. */ - deferredUpdate: (OrcadManagedDeferral & { deferredAt: string }) | null -} diff --git a/src/shared/orcad-migration-catalog-state.ts b/src/shared/orcad-migration-catalog-state.ts deleted file mode 100644 index 9878978cd24..00000000000 --- a/src/shared/orcad-migration-catalog-state.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - normalizeOrcadMigrationImportReceipts, - type OrcadMigrationCatalogAbortResult, - type OrcadMigrationCatalogState, - type OrcadMigrationManifest -} from './orcad-migration-manifest' -import { parseOrcadMigrationSnapshotUploadStates } from './orcad-migration-scrollback' - -export function parseOrcadMigrationCatalogState( - value: unknown, - manifest: OrcadMigrationManifest -): OrcadMigrationCatalogState { - const record = requireRecord(value) - if ( - record.migrationId !== manifest.migrationId || - record.manifestSha256 !== manifest.manifestSha256 - ) { - throw new Error('orcad_migration_catalog_state_identity_mismatch') - } - if (record.state === 'absent') { - return { state: 'absent', ...migrationIdentity(manifest) } - } - if (record.state === 'staged') { - const stagedAt = requireDate(record.stagedAt) - const snapshotUploads = parseOrcadMigrationSnapshotUploadStates( - record.snapshotUploads, - manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - ) - return { - state: 'staged', - ...migrationIdentity(manifest), - stagedAt, - ...(snapshotUploads ? { snapshotUploads } : {}) - } - } - if (record.state === 'committed') { - const receipt = normalizeOrcadMigrationImportReceipts([record.receipt])[0] - if ( - !receipt || - receipt.migrationId !== manifest.migrationId || - receipt.manifestSha256 !== manifest.manifestSha256 - ) { - throw new Error('orcad_migration_catalog_state_receipt_invalid') - } - return { state: 'committed', ...migrationIdentity(manifest), receipt } - } - throw new Error('orcad_migration_catalog_state_invalid') -} - -export function parseOrcadMigrationCatalogAbortResult( - value: unknown, - manifest: OrcadMigrationManifest -): OrcadMigrationCatalogAbortResult { - const state = parseOrcadMigrationCatalogState(value, manifest) - const record = requireRecord(value) - if (typeof record.aborted !== 'boolean') { - throw new Error('orcad_migration_catalog_abort_result_invalid') - } - if ( - record.durableAbsent !== undefined && - (record.durableAbsent !== true || state.state !== 'absent') - ) { - throw new Error('orcad_migration_catalog_abort_durability_invalid') - } - return { - ...state, - aborted: record.aborted, - ...(record.durableAbsent === true ? { durableAbsent: true as const } : {}) - } -} - -function migrationIdentity(manifest: OrcadMigrationManifest) { - return { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } -} - -function requireRecord(value: unknown): Record { - if (!isRecord(value)) { - throw new Error('orcad_migration_catalog_state_invalid') - } - return value -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function requireDate(value: unknown): string { - if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) { - throw new Error('orcad_migration_catalog_state_staged_at_invalid') - } - return value -} diff --git a/src/shared/orcad-migration-client-state-parsing.ts b/src/shared/orcad-migration-client-state-parsing.ts deleted file mode 100644 index 708e4e78e30..00000000000 --- a/src/shared/orcad-migration-client-state-parsing.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { parseHostStableKey } from './automation-owner-key' -import { - MAX_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS, - type ClientHostedBrowserCloseIntent -} from './client-hosted-browser-close-intent' -import type { - OrcadMigrationClientHostedBrowserCloseIntent, - OrcadMigrationUiRoutingState -} from './orcad-migration-client-state' -import type { PersistedMobileClientTabSelections } from './persisted-state-types' -import type { ManualRepoOrderEntry, WorkspaceHostOrder } from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' -import { - isRecord, - isWorkspaceHostId, - isWorkspaceHostScope, - nullableString, - parseSavedPortForwards as parseSavedPortForwardsValue, - parseStringArray, - requiredRecord -} from './orcad-migration-client-state-value-validation' - -export const MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS = 4_096 -export const MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES = 16_384 -export const MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS = 256 -export const MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS = - MAX_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS - -export function parseClientHostedBrowserCloseIntents( - value: unknown -): OrcadMigrationClientHostedBrowserCloseIntent[] { - if ( - !Array.isArray(value) || - value.length > MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS - ) { - throw new Error('orcad_migration_dormant_client_close_intents_invalid') - } - const seen = new Set() - return value.map((entry) => { - const record = requiredRecord(entry, 'orcad_migration_dormant_client_close_intent_invalid') - if ( - typeof record.sourceEnvironmentId !== 'string' || - !record.sourceEnvironmentId || - record.sourceEnvironmentId.length > 256 - ) { - throw new Error('orcad_migration_dormant_client_close_intent_environment_invalid') - } - if (typeof record.browserPageId !== 'string' || !record.browserPageId) { - throw new Error('orcad_migration_dormant_client_close_intent_page_invalid') - } - if (typeof record.worktreeId !== 'string' || !record.worktreeId) { - throw new Error('orcad_migration_dormant_client_close_intent_worktree_invalid') - } - if (!Number.isSafeInteger(record.closedAt) || Number(record.closedAt) < 0) { - throw new Error('orcad_migration_dormant_client_close_intent_timestamp_invalid') - } - const parsed = { - sourceEnvironmentId: record.sourceEnvironmentId, - browserPageId: record.browserPageId, - worktreeId: record.worktreeId, - closedAt: Number(record.closedAt) - } satisfies OrcadMigrationClientHostedBrowserCloseIntent - const key = `${parsed.sourceEnvironmentId}\0${parsed.browserPageId}\0${parsed.worktreeId}` - if (seen.has(key)) { - throw new Error('orcad_migration_dormant_client_close_intent_duplicate') - } - seen.add(key) - return parsed - }) -} - -export function parseMobileSelections(value: unknown): PersistedMobileClientTabSelections { - const record = requiredRecord(value, 'orcad_migration_dormant_mobile_selections_invalid') - const result: PersistedMobileClientTabSelections = {} - let count = 0 - for (const [deviceId, byWorktree] of Object.entries(record)) { - if (!deviceId || !isRecord(byWorktree)) { - throw new Error('orcad_migration_dormant_mobile_selections_invalid') - } - const entries: PersistedMobileClientTabSelections[string] = {} - for (const [worktreeId, rawSelection] of Object.entries(byWorktree)) { - if (!worktreeId || !isRecord(rawSelection)) { - throw new Error('orcad_migration_dormant_mobile_selection_invalid') - } - const activeTabId = nullableString(rawSelection.activeTabId) - const activeGroupId = nullableString(rawSelection.activeGroupId) - const activeTabIdByGroupId = requiredRecord( - rawSelection.activeTabIdByGroupId, - 'orcad_migration_dormant_mobile_selection_tabs_invalid' - ) - const tabs: Record = {} - for (const [groupId, tabId] of Object.entries(activeTabIdByGroupId)) { - if (!groupId || typeof tabId !== 'string' || !tabId) { - throw new Error('orcad_migration_dormant_mobile_selection_tabs_invalid') - } - tabs[groupId] = tabId - } - if (!activeTabId && !activeGroupId && Object.keys(tabs).length === 0) { - continue - } - entries[worktreeId] = { activeTabId, activeGroupId, activeTabIdByGroupId: tabs } - count += 1 - if (count > MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS) { - throw new Error('orcad_migration_dormant_mobile_selections_too_many') - } - } - if (Object.keys(entries).length > 0) { - result[deviceId] = entries - } - } - return result -} - -export function parseUiRouting(value: unknown): OrcadMigrationUiRoutingState { - const record = requiredRecord(value, 'orcad_migration_dormant_ui_routing_invalid') - const result: OrcadMigrationUiRoutingState = {} - if ('lastActiveRepoId' in record) { - result.lastActiveRepoId = nullableString(record.lastActiveRepoId) - } - if ('lastActiveWorktreeId' in record) { - result.lastActiveWorktreeId = nullableString(record.lastActiveWorktreeId) - } - if ('filterRepoIds' in record) { - result.filterRepoIds = parseStringArray( - record.filterRepoIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - } - if ('showDotfilesByWorktree' in record) { - const entries = requiredRecord( - record.showDotfilesByWorktree, - 'orcad_migration_dormant_ui_dotfiles_invalid' - ) - if (Object.keys(entries).length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_too_many') - } - result.showDotfilesByWorktree = {} - for (const [key, enabled] of Object.entries(entries)) { - if (!key || typeof enabled !== 'boolean') { - throw new Error('orcad_migration_dormant_ui_dotfiles_invalid') - } - result.showDotfilesByWorktree[key] = enabled - } - } - if ('setupScriptPromptDismissedRepoIds' in record) { - result.setupScriptPromptDismissedRepoIds = parseStringArray( - record.setupScriptPromptDismissedRepoIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - } - if ('manualRepoOrder' in record) { - const entries = record.manualRepoOrder - if (!Array.isArray(entries) || entries.length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - result.manualRepoOrder = entries.map((entry) => { - const parsed = requiredRecord(entry, 'orcad_migration_dormant_ui_manual_order_invalid') - const hostId = parsed.hostId - if (!isWorkspaceHostId(hostId) || typeof parsed.repoId !== 'string' || !parsed.repoId) { - throw new Error('orcad_migration_dormant_ui_manual_order_invalid') - } - const orderEntry: ManualRepoOrderEntry = { hostId, repoId: parsed.repoId } - return orderEntry - }) - } - if ('workspaceHostScope' in record) { - if (!isWorkspaceHostScope(record.workspaceHostScope)) { - throw new Error('orcad_migration_dormant_ui_host_scope_invalid') - } - result.workspaceHostScope = record.workspaceHostScope - } - if ('visibleWorkspaceHostIds' in record) { - if (record.visibleWorkspaceHostIds !== null) { - const ids = parseStringArray( - record.visibleWorkspaceHostIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - const hostIds: WorkspaceHostOrder = ids.filter(isWorkspaceHostId) - if (hostIds.length !== ids.length) { - throw new Error('orcad_migration_dormant_ui_visible_hosts_invalid') - } - result.visibleWorkspaceHostIds = hostIds - } else { - result.visibleWorkspaceHostIds = null - } - } - if ('workspaceHostOrder' in record) { - const ids = parseStringArray( - record.workspaceHostOrder, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - const hostIds: WorkspaceHostOrder = ids.filter(isWorkspaceHostId) - if (hostIds.length !== ids.length) { - throw new Error('orcad_migration_dormant_ui_host_order_invalid') - } - result.workspaceHostOrder = hostIds - } - if ('automationHostFilter' in record) { - const filter = requiredRecord( - record.automationHostFilter, - 'orcad_migration_dormant_ui_automation_filter_invalid' - ) - if (filter.kind === 'all') { - result.automationHostFilter = { kind: 'all' } - } else if ( - filter.kind === 'host' && - typeof filter.hostKey === 'string' && - filter.hostKey && - parseHostStableKey(filter.hostKey) - ) { - result.automationHostFilter = { kind: 'host', hostKey: filter.hostKey } - } else { - throw new Error('orcad_migration_dormant_ui_automation_filter_invalid') - } - } - if ('acknowledgedAgentsByPaneKey' in record) { - const entries = requiredRecord( - record.acknowledgedAgentsByPaneKey, - 'orcad_migration_dormant_ui_acknowledgements_invalid' - ) - if (Object.keys(entries).length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_too_many') - } - result.acknowledgedAgentsByPaneKey = {} - for (const [key, timestamp] of Object.entries(entries)) { - if (!key || typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) { - throw new Error('orcad_migration_dormant_ui_acknowledgements_invalid') - } - result.acknowledgedAgentsByPaneKey[key] = timestamp - } - } - return result -} - -export function parseSavedPortForwards(value: unknown): SavedPortForward[] { - return parseSavedPortForwardsValue(value, MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS) -} - -export type { ClientHostedBrowserCloseIntent } diff --git a/src/shared/orcad-migration-client-state-value-validation.ts b/src/shared/orcad-migration-client-state-value-validation.ts deleted file mode 100644 index f099f247af6..00000000000 --- a/src/shared/orcad-migration-client-state-value-validation.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { WorkspaceHostScope } from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' - -export function parseStringArray(value: unknown, max: number): string[] { - if ( - !Array.isArray(value) || - value.length > max || - !value.every((entry) => typeof entry === 'string' && entry) - ) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - if (new Set(value).size !== value.length) { - throw new Error('orcad_migration_dormant_ui_routing_duplicate') - } - return [...value] -} - -export function positivePort(value: unknown, label: string): number { - if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65_535) { - throw new Error(`orcad_migration_dormant_saved_port_forward_${label}_invalid`) - } - return Number(value) -} - -export function parseSavedPortForwards(value: unknown, max: number): SavedPortForward[] { - if (!Array.isArray(value) || value.length > max) { - throw new Error('orcad_migration_dormant_saved_port_forwards_invalid') - } - const usedPorts = new Set() - return value.map((entry) => { - const record = requiredRecord(entry, 'orcad_migration_dormant_saved_port_forward_invalid') - const localPort = positivePort(record.localPort, 'local_port') - if (usedPorts.has(localPort)) { - throw new Error('orcad_migration_dormant_saved_port_forwards_duplicate') - } - usedPorts.add(localPort) - const remotePort = positivePort(record.remotePort, 'remote_port') - if (typeof record.remoteHost !== 'string' || !record.remoteHost.trim()) { - throw new Error('orcad_migration_dormant_saved_port_forward_invalid') - } - if (record.label !== undefined && typeof record.label !== 'string') { - throw new Error('orcad_migration_dormant_saved_port_forward_invalid') - } - return { - localPort, - remoteHost: record.remoteHost, - remotePort, - ...(record.label !== undefined ? { label: record.label } : {}) - } - }) -} - -export function nullableString(value: unknown): string | null { - if (value !== null && value !== undefined && (typeof value !== 'string' || value.length === 0)) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - return value == null ? null : value -} - -export function isWorkspaceHostScope(value: unknown): value is WorkspaceHostScope { - return value === 'all' || isWorkspaceHostId(value) -} - -export function isWorkspaceHostId(value: unknown): value is Exclude { - return ( - value === 'local' || - (typeof value === 'string' && (value.startsWith('ssh:') || value.startsWith('runtime:'))) - ) -} - -export function requiredRecord(value: unknown, error: string): Record { - if (!isRecord(value)) { - throw new Error(error) - } - return value -} - -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-client-state.ts b/src/shared/orcad-migration-client-state.ts deleted file mode 100644 index 8bdde396dc7..00000000000 --- a/src/shared/orcad-migration-client-state.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { PersistedAutomationHostFilter } from './automation-host-filter' -import type { PersistedMobileClientTabSelections } from './persisted-state-types' -import type { - ManualRepoOrderEntry, - VisibleWorkspaceHostIds, - WorkspaceHostOrder, - WorkspaceHostScope -} from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' -import { - parseClientHostedBrowserCloseIntents, - parseMobileSelections, - parseSavedPortForwards, - parseUiRouting -} from './orcad-migration-client-state-parsing' -import { isWorkspaceHostId, isRecord } from './orcad-migration-client-state-value-validation' -import type { ClientHostedBrowserCloseIntent } from './client-hosted-browser-close-intent' - -export { - MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES, - MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS, - MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS, - parseClientHostedBrowserCloseIntents, - parseMobileSelections, - parseSavedPortForwards, - parseUiRouting -} from './orcad-migration-client-state-parsing' - -export type OrcadMigrationUiRoutingState = { - lastActiveRepoId?: string | null - lastActiveWorktreeId?: string | null - filterRepoIds?: string[] - showDotfilesByWorktree?: Record - setupScriptPromptDismissedRepoIds?: string[] - manualRepoOrder?: ManualRepoOrderEntry[] - workspaceHostScope?: WorkspaceHostScope - visibleWorkspaceHostIds?: VisibleWorkspaceHostIds - workspaceHostOrder?: WorkspaceHostOrder - automationHostFilter?: PersistedAutomationHostFilter - acknowledgedAgentsByPaneKey?: Record -} - -export type OrcadMigrationClientHostedBrowserCloseIntent = ClientHostedBrowserCloseIntent & { - sourceEnvironmentId: string -} - -export type OrcadMigrationClientStatePayload = { - mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections - uiRouting?: OrcadMigrationUiRoutingState - savedPortForwards?: SavedPortForward[] - clientHostedBrowserCloseIntents?: OrcadMigrationClientHostedBrowserCloseIntent[] -} - -export function parseOrcadMigrationClientState(value: unknown): OrcadMigrationClientStatePayload { - if (!isRecord(value)) { - throw new Error('orcad_migration_dormant_client_state_invalid') - } - const mobile = - value.mobileClientTabSelectionsByDeviceId === undefined - ? undefined - : parseMobileSelections(value.mobileClientTabSelectionsByDeviceId) - const uiRouting = value.uiRouting === undefined ? undefined : parseUiRouting(value.uiRouting) - const savedPortForwards = - value.savedPortForwards === undefined - ? undefined - : parseSavedPortForwards(value.savedPortForwards) - const closeIntents = - value.clientHostedBrowserCloseIntents === undefined - ? undefined - : parseClientHostedBrowserCloseIntents(value.clientHostedBrowserCloseIntents) - return { - ...(mobile && Object.keys(mobile).length > 0 - ? { mobileClientTabSelectionsByDeviceId: mobile } - : {}), - ...(uiRouting && Object.keys(uiRouting).length > 0 ? { uiRouting } : {}), - ...(savedPortForwards && savedPortForwards.length > 0 ? { savedPortForwards } : {}), - ...(closeIntents && closeIntents.length > 0 - ? { clientHostedBrowserCloseIntents: closeIntents } - : {}) - } -} - -export function parseOrcadMigrationUiRoutingState(value: unknown): OrcadMigrationUiRoutingState { - return parseUiRouting(value) -} - -export function parseOrcadMigrationSavedPortForwards(value: unknown): SavedPortForward[] { - return parseSavedPortForwards(value) -} - -export function parseOrcadMigrationClientHostedBrowserCloseIntents( - value: unknown -): OrcadMigrationClientHostedBrowserCloseIntent[] { - return parseClientHostedBrowserCloseIntents(value) -} - -export function assertOrcadMigrationClientStateReferences(args: { - clientState: OrcadMigrationClientStatePayload | undefined - repositoryIds: ReadonlySet - owns: (ownerKey: string) => boolean -}): void { - const clientState = args.clientState - if (!clientState) { - return - } - for (const selections of Object.values(clientState.mobileClientTabSelectionsByDeviceId ?? {})) { - for (const worktreeId of Object.keys(selections)) { - if (!args.owns(worktreeId)) { - throw new Error('orcad_migration_dormant_mobile_selection_scope_invalid') - } - } - } - for (const intent of clientState.clientHostedBrowserCloseIntents ?? []) { - if (!intent.sourceEnvironmentId || !args.owns(intent.worktreeId)) { - throw new Error('orcad_migration_dormant_client_close_intent_scope_invalid') - } - } - const ui = clientState.uiRouting - if (!ui) { - return - } - if (ui.lastActiveRepoId && !args.repositoryIds.has(ui.lastActiveRepoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - if (ui.lastActiveWorktreeId && !args.owns(ui.lastActiveWorktreeId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - for (const repoId of ui.filterRepoIds ?? []) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const ownerKey of Object.keys(ui.showDotfilesByWorktree ?? {})) { - if (!args.owns(ownerKey)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const repoId of ui.setupScriptPromptDismissedRepoIds ?? []) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const entry of ui.manualRepoOrder ?? []) { - if (!args.repositoryIds.has(entry.repoId) || !isWorkspaceHostId(entry.hostId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } -} diff --git a/src/shared/orcad-migration-dormant-automation-fields.ts b/src/shared/orcad-migration-dormant-automation-fields.ts deleted file mode 100644 index 684e7b1fd3e..00000000000 --- a/src/shared/orcad-migration-dormant-automation-fields.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - optionalFinite, - optionalNullableString, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty -} from './orcad-migration-dormant-value-validation' - -// Field checks for dormant automations and their final runs; each throws its specific error. - -export function parsePrecheck(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_precheck_invalid') - requiredString(record.command, 'orcad_migration_dormant_automation_precheck_command_invalid') - requiredFinite( - record.timeoutSeconds, - 'orcad_migration_dormant_automation_precheck_timeout_invalid' - ) -} - -export function parseOutputSnapshot(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_output_invalid') - if (record.format !== 'plain_text') { - throw new Error('orcad_migration_dormant_automation_output_format_invalid') - } - requiredStringOrEmpty(record.content, 'orcad_migration_dormant_automation_output_content_invalid') - requiredFinite(record.capturedAt, 'orcad_migration_dormant_automation_output_time_invalid') - requiredBoolean(record.truncated, 'orcad_migration_dormant_automation_output_truncated_invalid') -} - -export function parsePrecheckResult(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_precheck_result_invalid') - requiredString( - record.command, - 'orcad_migration_dormant_automation_precheck_result_command_invalid' - ) - optionalFinite( - record.exitCode, - 'orcad_migration_dormant_automation_precheck_result_exit_invalid', - true - ) - requiredBoolean( - record.timedOut, - 'orcad_migration_dormant_automation_precheck_result_timeout_invalid' - ) - requiredFinite( - record.durationMs, - 'orcad_migration_dormant_automation_precheck_result_duration_invalid' - ) - for (const field of ['stdout', 'stderr'] as const) { - requiredStringOrEmpty(record[field], `orcad_migration_dormant_automation_${field}_invalid`) - requiredBoolean( - record[`${field}Truncated`], - `orcad_migration_dormant_automation_${field}_truncated_invalid` - ) - } - optionalNullableString( - record.error, - 'orcad_migration_dormant_automation_precheck_result_error_invalid' - ) - requiredFinite( - record.startedAt, - 'orcad_migration_dormant_automation_precheck_result_started_invalid' - ) - requiredFinite( - record.completedAt, - 'orcad_migration_dormant_automation_precheck_result_completed_invalid' - ) -} - -export function parseUsage(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_usage_invalid') - if (record.status !== 'known' && record.status !== 'unavailable') { - throw new Error('orcad_migration_dormant_automation_usage_status_invalid') - } - for (const field of [ - 'inputTokens', - 'outputTokens', - 'cacheReadTokens', - 'cacheWriteTokens', - 'reasoningOutputTokens', - 'totalTokens', - 'estimatedCostUsd' - ] as const) { - optionalFinite(record[field], `orcad_migration_dormant_automation_usage_${field}_invalid`, true) - } - requiredFinite(record.collectedAt, 'orcad_migration_dormant_automation_usage_collected_invalid') -} diff --git a/src/shared/orcad-migration-dormant-automation-validation.ts b/src/shared/orcad-migration-dormant-automation-validation.ts deleted file mode 100644 index 61787127ba9..00000000000 --- a/src/shared/orcad-migration-dormant-automation-validation.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { isTuiAgent } from './tui-agent-config' -import type { Automation, AutomationRun } from './automations-types' -import { getAutomationRunRepoId } from './automation-run-identity' -import { - assertUnique, - boundedArray, - optionalFinite, - optionalNullableString, - optionalPositiveInteger, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty -} from './orcad-migration-dormant-value-validation' -import { - parseOutputSnapshot, - parsePrecheck, - parsePrecheckResult, - parseUsage -} from './orcad-migration-dormant-automation-fields' - -const FINAL_RUN_STATUSES = new Set([ - 'completed', - 'dispatch_failed', - 'skipped_precheck', - 'skipped_missed', - 'skipped_unavailable', - 'skipped_needs_interactive_auth' -]) - -export function parseOrcadMigrationDormantAutomations(value: unknown): Automation[] { - const automations = boundedArray(value, parseAutomation, 'automations') - assertUnique(automations, (entry) => entry.id, 'automation') - return automations -} - -export function parseOrcadMigrationDormantAutomationRuns(value: unknown): AutomationRun[] { - const runs = boundedArray(value, parseAutomationRun, 'automation_runs') - assertUnique(runs, (entry) => entry.id, 'automation_run') - return runs -} - -export function assertOrcadMigrationDormantAutomationReferences(args: { - automations: readonly Automation[] - automationRuns: readonly AutomationRun[] - repositoryIds: ReadonlySet - owns: (value: string) => boolean -}): void { - const automationIds = new Set(args.automations.map((entry) => entry.id)) - for (const automation of args.automations) { - const repoId = getAutomationRunRepoId(automation) - if ( - !args.repositoryIds.has(repoId) || - !contextBelongsToDestination(automation.runContext, args.repositoryIds) || - !contextBelongsToDestination(automation.sourceContext, args.repositoryIds) || - (automation.workspaceId !== null && !args.owns(automation.workspaceId)) - ) { - throw new Error('orcad_migration_dormant_automation_scope_invalid') - } - } - for (const run of args.automationRuns) { - if ( - !automationIds.has(run.automationId) || - !contextBelongsToDestination(run.runContext, args.repositoryIds) || - !contextBelongsToDestination(run.sourceContext, args.repositoryIds) || - (run.workspaceId !== null && !args.owns(run.workspaceId)) - ) { - throw new Error('orcad_migration_dormant_automation_run_scope_invalid') - } - } -} - -function parseAutomation(value: unknown): Automation { - const copy = structuredClone(value) - if (!isMigratedAutomation(copy)) { - throw new Error('orcad_migration_dormant_automation_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedAutomation(value: unknown): value is Automation { - const record = requiredRecord(value, 'orcad_migration_dormant_automation_invalid') - requiredString(record.id, 'orcad_migration_dormant_automation_id_invalid') - requiredString(record.name, 'orcad_migration_dormant_automation_name_invalid') - requiredStringOrEmpty(record.prompt, 'orcad_migration_dormant_automation_prompt_invalid') - parsePrecheck(record.precheck) - if (!isTuiAgent(record.agentId)) { - throw new Error('orcad_migration_dormant_automation_agent_invalid') - } - parseContext(record.runContext, 'workspace-run') - parseContext(record.sourceContext, 'task-source') - requiredString(record.projectId, 'orcad_migration_dormant_automation_project_invalid') - if ( - record.executionTargetType !== 'local' || - record.executionTargetId !== 'local' || - record.executionTargetGeneration !== undefined || - record.schedulerOwner !== 'remote_host_service' || - record.enabled !== false - ) { - throw new Error('orcad_migration_dormant_automation_owner_invalid') - } - if (!['existing', 'new_per_run'].includes(String(record.workspaceMode))) { - throw new Error('orcad_migration_dormant_automation_workspace_mode_invalid') - } - optionalNullableString(record.workspaceId, 'orcad_migration_dormant_automation_workspace_invalid') - optionalNullableString(record.baseBranch, 'orcad_migration_dormant_automation_branch_invalid') - if ( - record.setupDecision !== undefined && - record.setupDecision !== 'run' && - record.setupDecision !== 'skip' - ) { - throw new Error('orcad_migration_dormant_automation_setup_decision_invalid') - } - requiredBoolean(record.reuseSession, 'orcad_migration_dormant_automation_reuse_invalid') - requiredString(record.timezone, 'orcad_migration_dormant_automation_timezone_invalid') - requiredString(record.rrule, 'orcad_migration_dormant_automation_rrule_invalid') - for (const field of [ - 'dtstart', - 'nextRunAt', - 'missedRunGraceMinutes', - 'createdAt', - 'updatedAt' - ] as const) { - requiredFinite(record[field], `orcad_migration_dormant_automation_${field}_invalid`) - } - optionalFinite(record.lastRunAt, 'orcad_migration_dormant_automation_last_run_invalid') - if (record.missedRunPolicy !== 'run_once_within_grace') { - throw new Error('orcad_migration_dormant_automation_missed_policy_invalid') - } - return true -} - -function parseAutomationRun(value: unknown): AutomationRun { - const copy = structuredClone(value) - if (!isMigratedAutomationRun(copy)) { - throw new Error('orcad_migration_dormant_automation_run_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedAutomationRun(value: unknown): value is AutomationRun { - const record = requiredRecord(value, 'orcad_migration_dormant_automation_run_invalid') - requiredString(record.id, 'orcad_migration_dormant_automation_run_id_invalid') - requiredString(record.automationId, 'orcad_migration_dormant_automation_run_owner_invalid') - parseContext(record.runContext, 'workspace-run') - parseContext(record.sourceContext, 'task-source') - requiredString(record.title, 'orcad_migration_dormant_automation_run_title_invalid') - requiredFinite(record.scheduledFor, 'orcad_migration_dormant_automation_run_schedule_invalid') - if (!FINAL_RUN_STATUSES.has(record.status)) { - throw new Error('orcad_migration_dormant_automation_run_status_invalid') - } - if (record.trigger !== 'scheduled' && record.trigger !== 'manual') { - throw new Error('orcad_migration_dormant_automation_run_trigger_invalid') - } - optionalNullableString( - record.workspaceId, - 'orcad_migration_dormant_automation_run_workspace_invalid' - ) - optionalNullableString( - record.workspaceDisplayName, - 'orcad_migration_dormant_automation_run_workspace_name_invalid' - ) - if (record.sessionKind !== 'terminal') { - throw new Error('orcad_migration_dormant_automation_run_session_invalid') - } - for (const field of [ - 'chatSessionId', - 'terminalSessionId', - 'terminalPaneKey', - 'terminalPtyId', - 'error' - ] as const) { - optionalNullableString(record[field], `orcad_migration_dormant_automation_run_${field}_invalid`) - } - parseOutputSnapshot(record.outputSnapshot) - parsePrecheckResult(record.precheckResult) - parseUsage(record.usage) - optionalFinite(record.startedAt, 'orcad_migration_dormant_automation_run_started_invalid', true) - optionalFinite( - record.dispatchedAt, - 'orcad_migration_dormant_automation_run_dispatched_invalid', - true - ) - requiredFinite(record.createdAt, 'orcad_migration_dormant_automation_run_created_invalid') - optionalPositiveInteger(record.runNumber, 'orcad_migration_dormant_automation_run_number_invalid') - optionalPositiveInteger( - record.occurrenceCount, - 'orcad_migration_dormant_automation_occurrence_count_invalid' - ) - optionalFinite( - record.lastOccurrenceAt, - 'orcad_migration_dormant_automation_last_occurrence_invalid' - ) - return true -} - -function parseContext(value: unknown, kind: 'workspace-run' | 'task-source'): void { - if (value === undefined || value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_context_invalid') - if (record.kind !== kind || record.hostId !== 'local') { - throw new Error('orcad_migration_dormant_automation_context_owner_invalid') - } - requiredString(record.projectId, 'orcad_migration_dormant_automation_context_project_invalid') - optionalNullableString( - record.projectHostSetupId, - 'orcad_migration_dormant_automation_context_setup_invalid' - ) - optionalNullableString(record.repoId, 'orcad_migration_dormant_automation_context_repo_invalid') - if (kind === 'workspace-run') { - requiredString( - record.projectHostSetupId, - 'orcad_migration_dormant_automation_context_setup_invalid' - ) - requiredString(record.repoId, 'orcad_migration_dormant_automation_context_repo_invalid') - requiredString(record.path, 'orcad_migration_dormant_automation_context_path_invalid') - } else if (!['github', 'gitlab', 'linear', 'jira'].includes(String(record.provider))) { - throw new Error('orcad_migration_dormant_automation_context_provider_invalid') - } -} - -function contextBelongsToDestination( - context: Automation['runContext'] | Automation['sourceContext'], - repositoryIds: ReadonlySet -): boolean { - if (!context) { - return true - } - if (context.hostId !== 'local') { - return false - } - if (context.repoId && !repositoryIds.has(context.repoId)) { - return false - } - return !context.projectHostSetupId || context.projectHostSetupId === context.repoId -} diff --git a/src/shared/orcad-migration-dormant-session-validation.test.ts b/src/shared/orcad-migration-dormant-session-validation.test.ts deleted file mode 100644 index 0c3e31937b4..00000000000 --- a/src/shared/orcad-migration-dormant-session-validation.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - type PersistedClientHostedBrowserPage -} from './client-hosted-browser-page-record' -import type { BrowserWorkspace } from './browser-workspace-types' -import { assertOrcadMigrationDormantWorkspaceSessionReferences } from './orcad-migration-dormant-session-validation' -import { parseOrcadMigrationClientState } from './orcad-migration-client-state' -import type { WorkspaceSessionState } from './workspace-session-state-types' - -const OWNER = 'repo-1::/srv/worktree' -const BROWSER_WORKSPACE_ID = 'browser-workspace-1' - -function browserWorkspace(): BrowserWorkspace { - return { - id: BROWSER_WORKSPACE_ID, - worktreeId: OWNER, - activePageId: null, - pageIds: [], - url: 'about:blank', - title: 'Browser', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } -} - -function clientPage( - browserPageId: string, - workspaceId = BROWSER_WORKSPACE_ID -): PersistedClientHostedBrowserPage { - return { - v: CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - browserPageId, - workspaceId, - browserProfileId: 'profile-1', - url: 'https://example.test/', - title: 'Example', - pairedDeviceId: 'device-1', - savedAt: 1 - } -} - -function validate(session: WorkspaceSessionState): void { - assertOrcadMigrationDormantWorkspaceSessionReferences({ - session, - owns: (ownerKey) => ownerKey === OWNER, - repositoryIds: new Set(['repo-1']) - }) -} - -function sessionWithPages(pages: ReturnType[]): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - browserTabsByWorktree: { [OWNER]: [browserWorkspace()] }, - clientHostedBrowserPagesByWorktree: { [OWNER]: pages } - } -} - -describe('dormant client-hosted browser page migration validation', () => { - it('accepts held pages whose workspace id belongs to the transferred browser workspace', () => { - expect(() => validate(sessionWithPages([clientPage('page-1')]))).not.toThrow() - }) - - it('rejects a page that names a browser workspace outside its owner', () => { - expect(() => validate(sessionWithPages([clientPage('page-1', 'browser-missing')]))).toThrow( - 'orcad_migration_dormant_workspace_session_client_page_scope_invalid' - ) - }) - - it('rejects duplicate client-hosted page identities', () => { - expect(() => validate(sessionWithPages([clientPage('page-1'), clientPage('page-1')]))).toThrow( - 'orcad_migration_dormant_workspace_session_identity_duplicate' - ) - }) - - it('accepts scoped client-owned close intents alongside transferred pages', () => { - const session = sessionWithPages([clientPage('page-1')]) - session.clientHostedBrowserCloseIntentsByEnvironment = { - 'old-environment': [{ browserPageId: 'page-1', worktreeId: OWNER, closedAt: 10 }] - } - expect(() => validate(session)).not.toThrow() - }) - - it('parses migration close-intent envelopes with source environment identity', () => { - expect( - parseOrcadMigrationClientState({ - clientHostedBrowserCloseIntents: [ - { - sourceEnvironmentId: 'old-environment', - browserPageId: 'page-1', - worktreeId: OWNER, - closedAt: 10 - } - ] - }) - ).toEqual({ - clientHostedBrowserCloseIntents: [ - { - sourceEnvironmentId: 'old-environment', - browserPageId: 'page-1', - worktreeId: OWNER, - closedAt: 10 - } - ] - }) - }) - - it('allows global browser URL history to remain client-owned', () => { - const session = sessionWithPages([]) - session.browserUrlHistory = [ - { - url: 'https://example.test/', - normalizedUrl: 'https://example.test/', - title: 'Example', - lastVisitedAt: 10, - visitCount: 1 - } - ] - expect(() => validate(session)).not.toThrow() - }) - - it('accepts markdown visibility only when it names a persisted open file', () => { - const session = sessionWithPages([]) - session.openFilesByWorktree = { - [OWNER]: [ - { - filePath: '/srv/worktree/README.md', - relativePath: 'README.md', - worktreeId: OWNER, - language: 'markdown' - } - ] - } - session.markdownFrontmatterVisible = { '/srv/worktree/README.md': false } - expect(() => validate(session)).not.toThrow() - session.markdownFrontmatterVisible = { '/srv/worktree/missing.md': false } - expect(() => validate(session)).toThrow( - 'orcad_migration_dormant_workspace_session_markdown_scope_invalid' - ) - }) - - it('accepts a terminal layout referenced only by the unified tab model', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: {}, - unifiedTabs: { - [OWNER]: [ - { - id: 'terminal-tab-1', - entityId: 'terminal-tab-1', - groupId: 'group-1', - worktreeId: OWNER, - contentType: 'terminal' as const, - label: 'Terminal', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'terminal-tab-1': { - root: { type: 'leaf' as const, leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null - } - } - } - expect(() => validate(session)).not.toThrow() - }) -}) diff --git a/src/shared/orcad-migration-dormant-session-validation.ts b/src/shared/orcad-migration-dormant-session-validation.ts deleted file mode 100644 index 138e9b41fab..00000000000 --- a/src/shared/orcad-migration-dormant-session-validation.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type { WorkspaceSessionState } from './workspace-session-state-types' -import { parseWorkspaceSession } from './workspace-session-schema' - -export function parseOrcadMigrationDormantWorkspaceSession(value: unknown): WorkspaceSessionState { - const parsed = parseWorkspaceSession(value) - if (!parsed.ok) { - throw new Error(`orcad_migration_dormant_workspace_session_invalid:${parsed.error}`) - } - return parsed.value -} - -export function assertOrcadMigrationDormantWorkspaceSessionReferences(args: { - session: WorkspaceSessionState - owns: (ownerKey: string) => boolean - repositoryIds: ReadonlySet -}): void { - const { session } = args - const terminalTabIds = new Set() - const browserWorkspaceIds = new Set() - const browserWorkspaceOwnerById = new Map() - const clientHostedBrowserPageIds = new Set() - const clientHostedBrowserPageOwnerById = new Map() - const unifiedTabIds = new Set() - const tabGroupIds = new Set() - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - assertOwner(args.owns, ownerKey) - for (const tab of tabs) { - assertOwner(args.owns, tab.worktreeId) - if (tab.ptyId) { - throw new Error('orcad_migration_dormant_workspace_session_pty_invalid') - } - addUniqueSessionId(terminalTabIds, tab.id) - } - } - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - assertOwner(args.owns, ownerKey) - for (const file of files) { - assertOwner(args.owns, file.worktreeId) - if (file.externalSshTargetId) { - throw new Error('orcad_migration_dormant_workspace_session_external_file_invalid') - } - if (file.runtimeEnvironmentId) { - throw new Error('orcad_migration_dormant_workspace_session_file_runtime_invalid') - } - } - } - const markdownFileIdCounts = new Map() - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - if (!args.owns(ownerKey)) { - continue - } - for (const file of files) { - for (const fileId of [ - file.filePath, - ownedEditorFileId(file.filePath, ownerKey, file.runtimeEnvironmentId) - ]) { - markdownFileIdCounts.set(fileId, (markdownFileIdCounts.get(fileId) ?? 0) + 1) - } - } - } - for (const fileId of Object.keys(session.markdownFrontmatterVisible ?? {})) { - if (markdownFileIdCounts.get(fileId) !== 1) { - throw new Error('orcad_migration_dormant_workspace_session_markdown_scope_invalid') - } - } - for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { - assertOwner(args.owns, ownerKey) - for (const workspace of workspaces) { - assertOwner(args.owns, workspace.worktreeId) - if (workspace.sessionProfileId || workspace.sessionPartition) { - throw new Error('orcad_migration_dormant_workspace_session_browser_profile_invalid') - } - addUniqueSessionId(browserWorkspaceIds, workspace.id) - browserWorkspaceOwnerById.set(workspace.id, ownerKey) - } - } - for (const [workspaceId, pages] of Object.entries(session.browserPagesByWorkspace ?? {})) { - if (!browserWorkspaceIds.has(workspaceId)) { - throw new Error('orcad_migration_dormant_workspace_session_browser_page_scope_invalid') - } - pages.forEach((page) => assertOwner(args.owns, page.worktreeId)) - } - for (const [ownerKey, pages] of Object.entries( - session.clientHostedBrowserPagesByWorktree ?? {} - )) { - assertOwner(args.owns, ownerKey) - pages.forEach((page) => { - if ( - !browserWorkspaceIds.has(page.workspaceId) || - browserWorkspaceOwnerById.get(page.workspaceId) !== ownerKey - ) { - throw new Error('orcad_migration_dormant_workspace_session_client_page_scope_invalid') - } - addUniqueSessionId(clientHostedBrowserPageIds, page.browserPageId) - clientHostedBrowserPageOwnerById.set(page.browserPageId, ownerKey) - }) - } - const closeIntentKeys = new Set() - for (const [environmentId, intents] of Object.entries( - session.clientHostedBrowserCloseIntentsByEnvironment ?? {} - )) { - if (!environmentId) { - throw new Error('orcad_migration_dormant_workspace_session_close_intent_scope_invalid') - } - for (const intent of intents) { - assertOwner(args.owns, intent.worktreeId) - const ownerKey = clientHostedBrowserPageOwnerById.get(intent.browserPageId) - if (!clientHostedBrowserPageIds.has(intent.browserPageId) || ownerKey !== intent.worktreeId) { - throw new Error('orcad_migration_dormant_workspace_session_close_intent_scope_invalid') - } - const key = `${environmentId}\0${intent.browserPageId}\0${intent.worktreeId}` - if (closeIntentKeys.has(key)) { - throw new Error('orcad_migration_dormant_workspace_session_identity_duplicate') - } - closeIntentKeys.add(key) - } - } - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - assertOwner(args.owns, ownerKey) - for (const tab of tabs) { - assertOwner(args.owns, tab.worktreeId) - if (tab.contentType === 'terminal') { - // Unified terminal tabs normally mirror tabsByWorktree, but a session written during - // model rollout can contain only this representation. Layouts still belong to it. - terminalTabIds.add(tab.id) - terminalTabIds.add(tab.entityId) - } - addUniqueSessionId(unifiedTabIds, tab.id) - if (tab.executionHostId !== undefined && tab.executionHostId !== 'local') { - throw new Error('orcad_migration_dormant_workspace_session_host_invalid') - } - } - } - for (const [ownerKey, groups] of Object.entries(session.tabGroups ?? {})) { - assertOwner(args.owns, ownerKey) - groups.forEach((group) => { - assertOwner(args.owns, group.worktreeId) - addUniqueSessionId(tabGroupIds, group.id) - }) - } - assertOwnerRecordKeys(args.owns, [ - session.activeFileIdByWorktree, - session.activeBrowserTabIdByWorktree, - session.activeTabTypeByWorktree, - session.activeTabIdByWorktree, - session.tabGroupLayouts, - session.activeGroupIdByWorktree, - session.lastVisitedAtByWorktreeId, - session.defaultTerminalTabsAppliedByWorktreeId - ]) - for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { - if ( - !terminalTabIds.has(tabId) || - Object.keys(layout.ptyIdsByLeafId ?? {}).length > 0 || - Object.keys(layout.buffersByLeafId ?? {}).length > 0 - ) { - throw new Error('orcad_migration_dormant_workspace_session_layout_invalid') - } - } - if (session.activeRepoId !== null && !args.repositoryIds.has(session.activeRepoId)) { - throw new Error('orcad_migration_dormant_workspace_session_focus_scope_invalid') - } - if (session.activeWorktreeId !== null) { - assertOwner(args.owns, session.activeWorktreeId) - } - if (session.activeWorkspaceKey != null) { - assertOwner(args.owns, session.activeWorkspaceKey) - } - if ( - session.activeWorkspaceExecutionHostId !== undefined && - session.activeWorkspaceExecutionHostId !== null && - session.activeWorkspaceExecutionHostId !== 'local' - ) { - throw new Error('orcad_migration_dormant_workspace_session_host_invalid') - } - if (session.activeTabId !== null && !terminalTabIds.has(session.activeTabId)) { - throw new Error('orcad_migration_dormant_workspace_session_focus_scope_invalid') - } - for (const entry of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { - assertOwner(args.owns, entry.worktreeId) - } - for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - assertOwner(args.owns, record.worktreeId) - if ( - record.connectionId != null || - ('automaticResumeBlockedBy' in record && record.automaticResumeBlockedBy !== undefined) - ) { - throw new Error('orcad_migration_dormant_workspace_session_agent_authority_invalid') - } - if ( - (record.origin === 'quit' || record.origin === 'live') && - !paneBelongsToTerminalLayout(record, session, terminalTabIds) - ) { - throw new Error('orcad_migration_dormant_workspace_session_agent_pane_invalid') - } - } - for (const repoId of Object.keys(session.terminalTopologyRevisionByRepoId ?? {})) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_workspace_session_topology_scope_invalid') - } - } - if ( - (session.activeWorktreeIdsOnShutdown?.length ?? 0) > 0 || - (session.activeConnectionIdsAtShutdown?.length ?? 0) > 0 || - Object.keys(session.remoteSessionIdsByTabId ?? {}).length > 0 || - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length > 0 - ) { - throw new Error('orcad_migration_dormant_workspace_session_live_or_client_state_invalid') - } -} - -function ownedEditorFileId( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): string { - const runtimeKey = runtimeEnvironmentId?.trim() || 'local' - return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` -} - -function paneBelongsToTerminalLayout( - record: NonNullable[string], - session: WorkspaceSessionState, - tabIds: ReadonlySet -): boolean { - const separator = record.paneKey.lastIndexOf(':') - if (separator < 1) { - return false - } - const tabId = record.paneKey.slice(0, separator) - const leafId = record.paneKey.slice(separator + 1) - if ((record.tabId !== undefined && record.tabId !== tabId) || !tabIds.has(tabId)) { - return false - } - return terminalLayoutContainsLeaf(session.terminalLayoutsByTabId[tabId]?.root, leafId) -} - -function terminalLayoutContainsLeaf( - node: WorkspaceSessionState['terminalLayoutsByTabId'][string]['root'] | undefined, - leafId: string -): boolean { - return Boolean( - node && - (node.type === 'leaf' - ? node.leafId === leafId - : terminalLayoutContainsLeaf(node.first, leafId) || - terminalLayoutContainsLeaf(node.second, leafId)) - ) -} - -function assertOwnerRecordKeys( - owns: (ownerKey: string) => boolean, - records: (Record | undefined)[] -): void { - for (const record of records) { - Object.keys(record ?? {}).forEach((ownerKey) => assertOwner(owns, ownerKey)) - } -} - -function assertOwner(owns: (ownerKey: string) => boolean, ownerKey: string): void { - if (!owns(ownerKey)) { - throw new Error('orcad_migration_dormant_workspace_session_scope_invalid') - } -} - -function addUniqueSessionId(ids: Set, id: string): void { - if (ids.has(id)) { - throw new Error('orcad_migration_dormant_workspace_session_identity_duplicate') - } - ids.add(id) -} diff --git a/src/shared/orcad-migration-dormant-state-entry-validation.ts b/src/shared/orcad-migration-dormant-state-entry-validation.ts deleted file mode 100644 index 5341b91b578..00000000000 --- a/src/shared/orcad-migration-dormant-state-entry-validation.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { - OrcadMigrationDormantRetiredNames, - OrcadMigrationDormantRetirementNamespace, - OrcadMigrationDormantWorktreeLineage, - OrcadMigrationDormantWorkspaceLineage, - OrcadMigrationDormantWorktreeMeta -} from './orcad-migration-manifest' -import type { SparsePreset } from './worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' -import type { WorktreeMeta } from './worktree/meta-types' -import type { RetiredNameRegistry } from './worktree/retired-name-registry' -import { parseWorkspaceKey } from './workspace-scope' -import { - isString, - optionalNullableString, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty, - stringArray -} from './orcad-migration-dormant-value-validation' - -const MAX_RETIRED_NAMES = 2_048 - -export function parseWorktreeMetaEntry(value: unknown): OrcadMigrationDormantWorktreeMeta { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_meta_invalid') - const meta = structuredClone(record.meta) - if (!isMigratedWorktreeMeta(meta)) { - throw new Error('orcad_migration_dormant_worktree_meta_value_invalid') - } - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - worktreeId: requiredString(record.worktreeId, 'orcad_migration_dormant_worktree_id_invalid'), - meta - } -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorktreeMeta(value: unknown): value is WorktreeMeta { - const meta = requiredRecord(value, 'orcad_migration_dormant_worktree_meta_value_invalid') - requiredStringOrEmpty( - meta.displayName, - 'orcad_migration_dormant_worktree_meta_display_name_invalid' - ) - requiredStringOrEmpty(meta.comment, 'orcad_migration_dormant_worktree_meta_comment_invalid') - requiredBoolean(meta.isArchived, 'orcad_migration_dormant_worktree_meta_archived_invalid') - requiredBoolean(meta.isUnread, 'orcad_migration_dormant_worktree_meta_unread_invalid') - requiredBoolean(meta.isPinned, 'orcad_migration_dormant_worktree_meta_pinned_invalid') - requiredFinite(meta.sortOrder, 'orcad_migration_dormant_worktree_meta_sort_order_invalid') - requiredFinite(meta.lastActivityAt, 'orcad_migration_dormant_worktree_meta_activity_invalid') - return true -} - -export function parseWorktreeLineageEntry(value: unknown): OrcadMigrationDormantWorktreeLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_lineage_invalid') - const lineage = parseWorktreeLineage(record.lineage) - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - worktreeId: requiredString(record.worktreeId, 'orcad_migration_dormant_worktree_id_invalid'), - lineage - } -} - -export function parseWorkspaceLineageEntry(value: unknown): OrcadMigrationDormantWorkspaceLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_workspace_lineage_invalid') - const lineage = parseWorkspaceLineage(record.lineage) - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - childWorkspaceKey: requiredString( - record.childWorkspaceKey, - 'orcad_migration_dormant_workspace_key_invalid' - ), - lineage - } -} - -function parseWorktreeLineage(value: unknown): WorktreeLineage { - const copy = structuredClone(value) - if (!isMigratedWorktreeLineage(copy)) { - throw new Error('orcad_migration_dormant_worktree_lineage_value_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorktreeLineage(value: unknown): value is WorktreeLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_lineage_value_invalid') - requiredString(record.worktreeId, 'orcad_migration_dormant_lineage_worktree_invalid') - requiredString(record.worktreeInstanceId, 'orcad_migration_dormant_lineage_instance_invalid') - requiredString(record.parentWorktreeId, 'orcad_migration_dormant_lineage_parent_invalid') - requiredString( - record.parentWorktreeInstanceId, - 'orcad_migration_dormant_lineage_parent_instance_invalid' - ) - parseLineageBase(record) - return true -} - -function parseWorkspaceLineage(value: unknown): WorkspaceLineage { - const lineage = structuredClone(value) - if (!isMigratedWorkspaceLineage(lineage)) { - throw new Error('orcad_migration_dormant_workspace_lineage_value_invalid') - } - return { - ...lineage, - childInstanceId: lineage.childInstanceId ?? null, - parentInstanceId: lineage.parentInstanceId ?? null - } -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorkspaceLineage(value: unknown): value is WorkspaceLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_workspace_lineage_value_invalid') - const child = requiredString( - record.childWorkspaceKey, - 'orcad_migration_dormant_lineage_child_invalid' - ) - const parent = requiredString( - record.parentWorkspaceKey, - 'orcad_migration_dormant_lineage_parent_invalid' - ) - if (!parseWorkspaceKey(child) || !parseWorkspaceKey(parent)) { - throw new Error('orcad_migration_dormant_lineage_workspace_key_invalid') - } - parseLineageBase(record) - optionalNullableString( - record.childInstanceId, - 'orcad_migration_dormant_lineage_child_instance_invalid' - ) - optionalNullableString( - record.parentInstanceId, - 'orcad_migration_dormant_lineage_parent_instance_invalid' - ) - return true -} - -function parseLineageBase(record: Record): void { - if (!['orchestration', 'cli', 'manual'].includes(String(record.origin))) { - throw new Error('orcad_migration_dormant_lineage_origin_invalid') - } - const capture = requiredRecord(record.capture, 'orcad_migration_dormant_lineage_capture_invalid') - if (!['explicit', 'inferred'].includes(String(capture.confidence))) { - throw new Error('orcad_migration_dormant_lineage_confidence_invalid') - } - requiredString(capture.source, 'orcad_migration_dormant_lineage_source_invalid') - requiredFinite(record.createdAt, 'orcad_migration_dormant_lineage_created_at_invalid') -} - -export function parseSparsePreset(value: unknown): SparsePreset { - const copy = structuredClone(value) - if (!isMigratedSparsePreset(copy)) { - throw new Error('orcad_migration_dormant_sparse_preset_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedSparsePreset(value: unknown): value is SparsePreset { - const record = requiredRecord(value, 'orcad_migration_dormant_sparse_preset_invalid') - requiredString(record.id, 'orcad_migration_dormant_sparse_preset_id_invalid') - requiredString(record.repoId, 'orcad_migration_dormant_sparse_preset_repo_invalid') - requiredString(record.name, 'orcad_migration_dormant_sparse_preset_name_invalid') - if (!Array.isArray(record.directories) || !record.directories.every(isString)) { - throw new Error('orcad_migration_dormant_sparse_preset_directories_invalid') - } - requiredFinite(record.createdAt, 'orcad_migration_dormant_sparse_preset_created_at_invalid') - requiredFinite(record.updatedAt, 'orcad_migration_dormant_sparse_preset_updated_at_invalid') - return true -} - -export function parseRetiredNames(value: unknown): OrcadMigrationDormantRetiredNames { - const record = requiredRecord(value, 'orcad_migration_dormant_retired_names_invalid') - return { - repoId: requiredString(record.repoId, 'orcad_migration_dormant_retired_names_repo_invalid'), - registry: parseRetiredNameRegistry(record.registry) - } -} - -export function parseRetirementNamespace(value: unknown): OrcadMigrationDormantRetirementNamespace { - const record = requiredRecord(value, 'orcad_migration_dormant_retirement_namespace_invalid') - const sourceNamespaceKeys = stringArray(record.sourceNamespaceKeys) - if (sourceNamespaceKeys.length === 0) { - throw new Error('orcad_migration_dormant_retirement_namespace_sources_invalid') - } - return { - sourceNamespaceKeys, - namespaceKey: requiredString( - record.namespaceKey, - 'orcad_migration_dormant_retirement_namespace_key_invalid' - ), - registry: parseRetiredNameRegistry(record.registry) - } -} - -function parseRetiredNameRegistry(value: unknown): RetiredNameRegistry { - const record = requiredRecord(value, 'orcad_migration_dormant_retired_registry_invalid') - if (!Number.isInteger(record.exhaustedTiers) || Number(record.exhaustedTiers) < 0) { - throw new Error('orcad_migration_dormant_retired_registry_watermark_invalid') - } - const names = stringArray(record.names, MAX_RETIRED_NAMES) - return { exhaustedTiers: Number(record.exhaustedTiers), names } -} diff --git a/src/shared/orcad-migration-dormant-state-validation.ts b/src/shared/orcad-migration-dormant-state-validation.ts deleted file mode 100644 index 87793ef1133..00000000000 --- a/src/shared/orcad-migration-dormant-state-validation.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { OrcadMigrationDormantStatePayload } from './orcad-migration-manifest' -import { getRepoIdFromWorktreeId } from './worktree/id' -import { parseWorkspaceKey } from './workspace-scope' -import { - assertOrcadMigrationDormantWorkspaceSessionReferences, - parseOrcadMigrationDormantWorkspaceSession -} from './orcad-migration-dormant-session-validation' -import { - assertOrcadMigrationDormantAutomationReferences, - parseOrcadMigrationDormantAutomationRuns, - parseOrcadMigrationDormantAutomations -} from './orcad-migration-dormant-automation-validation' -import { - assertOrcadMigrationScrollbackReferences, - parseOrcadMigrationTerminalScrollbackSnapshots -} from './orcad-migration-scrollback' -import { - assertOrcadMigrationClientStateReferences, - parseOrcadMigrationClientState -} from './orcad-migration-client-state' -import { - assertUnique, - boundedArray, - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - requiredRecord -} from './orcad-migration-dormant-value-validation' -import { - parseRetiredNames, - parseRetirementNamespace, - parseSparsePreset, - parseWorkspaceLineageEntry, - parseWorktreeLineageEntry, - parseWorktreeMetaEntry -} from './orcad-migration-dormant-state-entry-validation' - -export { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS -} from './orcad-migration-dormant-value-validation' - -export const ORCAD_MIGRATION_DORMANT_STATE_VERSION = 1 as const -export function parseOrcadMigrationDormantState(value: unknown): OrcadMigrationDormantStatePayload { - const record = requiredRecord(value, 'orcad_migration_dormant_state_invalid') - if (record.version !== ORCAD_MIGRATION_DORMANT_STATE_VERSION) { - throw new Error('orcad_migration_dormant_state_version_unsupported') - } - const payload: OrcadMigrationDormantStatePayload = { - version: ORCAD_MIGRATION_DORMANT_STATE_VERSION, - worktreeMeta: boundedArray(record.worktreeMeta, parseWorktreeMetaEntry, 'worktree_meta'), - worktreeLineage: boundedArray( - record.worktreeLineage, - parseWorktreeLineageEntry, - 'worktree_lineage' - ), - workspaceLineage: boundedArray( - record.workspaceLineage, - parseWorkspaceLineageEntry, - 'workspace_lineage' - ), - sparsePresets: boundedArray(record.sparsePresets, parseSparsePreset, 'sparse_presets'), - retiredWorktreeNames: boundedArray( - record.retiredWorktreeNames, - parseRetiredNames, - 'retired_names' - ), - retiredWorktreeNamespaces: boundedArray( - record.retiredWorktreeNamespaces, - parseRetirementNamespace, - 'retirement_namespaces', - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES - ), - ...(record.workspaceSession === undefined - ? {} - : { workspaceSession: parseOrcadMigrationDormantWorkspaceSession(record.workspaceSession) }), - ...(record.terminalScrollbackSnapshots === undefined - ? {} - : { - terminalScrollbackSnapshots: parseOrcadMigrationTerminalScrollbackSnapshots( - record.terminalScrollbackSnapshots - ) - }), - ...(record.automations === undefined - ? {} - : { automations: parseOrcadMigrationDormantAutomations(record.automations) }), - ...(record.automationRuns === undefined - ? {} - : { automationRuns: parseOrcadMigrationDormantAutomationRuns(record.automationRuns) }), - ...(record.clientState === undefined - ? {} - : { clientState: parseOrcadMigrationClientState(record.clientState) }) - } - assertUnique(payload.worktreeMeta, (entry) => entry.worktreeId, 'worktree_meta') - assertUnique(payload.worktreeMeta, (entry) => entry.sourceKey, 'worktree_meta_source') - assertUnique(payload.worktreeLineage, (entry) => entry.worktreeId, 'worktree_lineage') - assertUnique(payload.worktreeLineage, (entry) => entry.sourceKey, 'worktree_lineage_source') - assertUnique(payload.workspaceLineage, (entry) => entry.childWorkspaceKey, 'workspace_lineage') - assertUnique(payload.workspaceLineage, (entry) => entry.sourceKey, 'workspace_lineage_source') - assertUnique(payload.sparsePresets, (entry) => `${entry.repoId}\0${entry.id}`, 'sparse_preset') - assertUnique(payload.retiredWorktreeNames, (entry) => entry.repoId, 'retired_names') - assertUnique( - payload.retiredWorktreeNamespaces, - (entry) => entry.namespaceKey, - 'retirement_namespace' - ) - return payload -} - -export function assertOrcadMigrationDormantStateReferences(args: { - dormantState: OrcadMigrationDormantStatePayload - repositoryIds: ReadonlySet - folderWorkspaceIds: ReadonlySet -}): void { - const owns = (value: string): boolean => { - const parsed = parseWorkspaceKey(value) - if (parsed?.type === 'folder') { - return args.folderWorkspaceIds.has(parsed.folderWorkspaceId) - } - const worktreeId = parsed?.type === 'worktree' ? parsed.worktreeId : value - return args.repositoryIds.has(getRepoIdFromWorktreeId(worktreeId)) - } - for (const entry of args.dormantState.worktreeMeta) { - if (!owns(entry.worktreeId) || entry.meta.hostId !== 'local') { - throw new Error('orcad_migration_dormant_worktree_meta_scope_invalid') - } - } - for (const entry of args.dormantState.worktreeLineage) { - if ( - entry.worktreeId !== entry.lineage.worktreeId || - !owns(entry.worktreeId) || - !owns(entry.lineage.parentWorktreeId) - ) { - throw new Error('orcad_migration_dormant_worktree_lineage_scope_invalid') - } - } - for (const entry of args.dormantState.workspaceLineage) { - if ( - entry.childWorkspaceKey !== entry.lineage.childWorkspaceKey || - !owns(entry.childWorkspaceKey) || - !owns(entry.lineage.parentWorkspaceKey) - ) { - throw new Error('orcad_migration_dormant_workspace_lineage_scope_invalid') - } - } - for (const preset of args.dormantState.sparsePresets) { - if (!args.repositoryIds.has(preset.repoId)) { - throw new Error('orcad_migration_dormant_sparse_preset_scope_invalid') - } - } - for (const entry of args.dormantState.retiredWorktreeNames) { - if (!args.repositoryIds.has(entry.repoId)) { - throw new Error('orcad_migration_dormant_retired_names_scope_invalid') - } - } - for (const entry of args.dormantState.retiredWorktreeNamespaces) { - if ( - !entry.namespaceKey.startsWith('local:') || - entry.sourceNamespaceKeys.some((key) => !key.startsWith('ssh:')) - ) { - throw new Error('orcad_migration_dormant_retirement_namespace_scope_invalid') - } - } - if (args.dormantState.workspaceSession) { - assertOrcadMigrationDormantWorkspaceSessionReferences({ - session: args.dormantState.workspaceSession, - owns, - repositoryIds: args.repositoryIds - }) - } - assertOrcadMigrationScrollbackReferences( - args.dormantState.workspaceSession, - args.dormantState.terminalScrollbackSnapshots ?? [] - ) - assertOrcadMigrationDormantAutomationReferences({ - automations: args.dormantState.automations ?? [], - automationRuns: args.dormantState.automationRuns ?? [], - owns, - repositoryIds: args.repositoryIds - }) - assertOrcadMigrationClientStateReferences({ - clientState: args.dormantState.clientState, - repositoryIds: args.repositoryIds, - owns - }) -} diff --git a/src/shared/orcad-migration-dormant-value-validation.ts b/src/shared/orcad-migration-dormant-value-validation.ts deleted file mode 100644 index beb7ea136f4..00000000000 --- a/src/shared/orcad-migration-dormant-value-validation.ts +++ /dev/null @@ -1,100 +0,0 @@ -export const MAX_ORCAD_MIGRATION_DORMANT_ROWS = 16_384 -export const MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES = 256 - -export function boundedArray( - value: unknown, - parse: (entry: unknown) => T, - label: string, - maximum = MAX_ORCAD_MIGRATION_DORMANT_ROWS -): T[] { - if (!Array.isArray(value)) { - throw new Error(`orcad_migration_dormant_${label}_invalid`) - } - if (value.length > maximum) { - throw new Error(`orcad_migration_dormant_${label}_too_many`) - } - return value.map(parse) -} - -export function stringArray(value: unknown, maximum = MAX_ORCAD_MIGRATION_DORMANT_ROWS): string[] { - if (!Array.isArray(value) || value.length > maximum) { - throw new Error('orcad_migration_dormant_string_array_invalid') - } - const result = value.filter(isString) - if ( - result.length !== value.length || - result.some((entry) => !entry) || - new Set(result).size !== result.length - ) { - throw new Error('orcad_migration_dormant_string_array_invalid') - } - return result -} - -export function assertUnique(values: T[], key: (value: T) => string, label: string): void { - const keys = values.map(key) - if (new Set(keys).size !== keys.length) { - throw new Error(`orcad_migration_dormant_${label}_duplicate`) - } -} - -export function requiredRecord(value: unknown, error: string): Record { - if (!isRecordValue(value)) { - throw new Error(error) - } - return value -} - -function isRecordValue(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function requiredString(value: unknown, error: string): string { - if (typeof value !== 'string' || !value) { - throw new Error(error) - } - return value -} - -export function requiredStringOrEmpty(value: unknown, error: string): string { - if (typeof value !== 'string') { - throw new Error(error) - } - return value -} - -export function optionalNullableString(value: unknown, error: string): string | null | undefined { - if (value !== undefined && value !== null && typeof value !== 'string') { - throw new Error(error) - } - return value -} - -export function requiredBoolean(value: unknown, error: string): void { - if (typeof value !== 'boolean') { - throw new Error(error) - } -} - -export function requiredFinite(value: unknown, error: string): void { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new Error(error) - } -} - -export function optionalFinite(value: unknown, error: string, nullable = false): void { - if (value === undefined || (nullable && value === null)) { - return - } - requiredFinite(value, error) -} - -export function optionalPositiveInteger(value: unknown, error: string): void { - if (value !== undefined && (!Number.isSafeInteger(value) || Number(value) < 1)) { - throw new Error(error) - } -} - -export function isString(value: unknown): value is string { - return typeof value === 'string' -} diff --git a/src/shared/orcad-migration-manifest-contract.test.ts b/src/shared/orcad-migration-manifest-contract.test.ts deleted file mode 100644 index 0a2ea6d6bba..00000000000 --- a/src/shared/orcad-migration-manifest-contract.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest' - -const digest = 'a'.repeat(64) - -function manifest(overrides: { version?: unknown; dormantState?: unknown } = {}) { - return { - version: overrides.version ?? ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-contract', - createdAt: '2026-10-01T12:00:00.000Z', - source: { sshTargetId: 'target', sshTargetGeneration: 3, targetLabel: 'Host' }, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [ - { - id: 'group-1', - name: 'Folders', - tabOrder: 0, - createdAt: 1, - updatedAt: 1, - parentPath: null, - parentGroupId: null, - isCollapsed: false, - color: null, - createdFrom: 'manual' - } - ], - folderWorkspaces: [ - { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Notes', - folderPath: '/srv/notes', - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - isArchived: false, - isUnread: false, - isPinned: false - } - ], - ...(overrides.dormantState === undefined ? {} : { dormantState: overrides.dormantState }) - }, - manifestSha256: digest - } -} - -function dormantLineage(childWorkspaceKey: string, parentWorkspaceKey: string) { - const lineage = { - childWorkspaceKey, - parentWorkspaceKey, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 1 - } - return { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [{ sourceKey: childWorkspaceKey, childWorkspaceKey, lineage }], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [] - } -} - -describe('orcad migration manifest contract', () => { - it('is an explicit version 1 artifact', () => { - expect(ORCAD_MIGRATION_MANIFEST_VERSION).toBe(1) - expect(parseOrcadMigrationManifest(manifest()).version).toBe(1) - }) - - it.each([2, 10, 0, '1', undefined, null])( - 'rejects manifest version %j instead of guessing its shape', - (version) => { - expect(() => parseOrcadMigrationManifest({ ...manifest(), version })).toThrow( - 'orcad_migration_manifest_version_unsupported' - ) - } - ) - - it('validates folder workspace keys alongside worktree workspace keys', () => { - const parsed = parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage('folder:folder-1', 'worktree:repo-1::/srv/repo-1') }) - ) - expect(parsed.payload.dormantState?.workspaceLineage[0]?.lineage).toMatchObject({ - childWorkspaceKey: 'folder:folder-1', - parentWorkspaceKey: 'worktree:repo-1::/srv/repo-1', - childInstanceId: null, - parentInstanceId: null - }) - }) - - it.each([ - ['a folder workspace outside the catalog', 'folder:folder-2'], - ['a worktree outside the catalog', 'worktree:repo-2::/srv/repo-2'] - ])('refuses lineage that names %s', (_label, key) => { - expect(() => - parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage(key, 'folder:folder-1') }) - ) - ).toThrow('orcad_migration_dormant_workspace_lineage_scope_invalid') - }) - - it('rejects a malformed workspace key rather than treating it as a worktree id', () => { - expect(() => - parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage('folder:', 'folder:folder-1') }) - ) - ).toThrow('orcad_migration_dormant_lineage_workspace_key_invalid') - }) -}) diff --git a/src/shared/orcad-migration-manifest-fields.ts b/src/shared/orcad-migration-manifest-fields.ts deleted file mode 100644 index 3a038fa0bd8..00000000000 --- a/src/shared/orcad-migration-manifest-fields.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Field checks shared by the manifest and receipt parsers; each throws a labeled error. - -export function boundedArray( - value: unknown, - maximum: number, - parse: (entry: unknown) => T, - label: string -): T[] { - if (!Array.isArray(value)) { - throw new Error(`orcad_migration_manifest_${label}_invalid`) - } - if (value.length > maximum) { - throw new Error(`orcad_migration_manifest_${label}_too_many`) - } - return value.map(parse) -} - -export function assertUniqueIds(values: { id: string }[], label: string): void { - const ids = new Set() - for (const value of values) { - if (ids.has(value.id)) { - throw new Error(`orcad_migration_manifest_${label}_duplicate_id`) - } - ids.add(value.id) - } -} - -export function boundedStringArray(value: unknown, maximum: number, label: string): string[] { - if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string' && entry)) { - throw new Error(`${label}_invalid`) - } - if (value.length > maximum || new Set(value).size !== value.length) { - throw new Error(`${label}_invalid`) - } - return [...value] -} - -export function requiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`${label}_invalid`) - } - return value -} - -export function requiredFiniteNumber(value: unknown, label: string): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new Error(`${label}_invalid`) - } - return value -} - -export function requiredDate(value: unknown, label: string): string { - const result = requiredString(value, label) - if (!Number.isFinite(Date.parse(result))) { - throw new Error(`${label}_invalid`) - } - return result -} - -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-manifest-validation.ts b/src/shared/orcad-migration-manifest-validation.ts deleted file mode 100644 index 8272463c5d0..00000000000 --- a/src/shared/orcad-migration-manifest-validation.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationImportReceipt, - OrcadMigrationManifest, - OrcadMigrationManifestSource -} from './orcad-migration-manifest' -import type { ProjectGroup } from './project-group-types' -import type { Repo } from './repo-types' -import { - assertUniqueIds, - boundedArray, - boundedStringArray, - isRecord, - requiredDate, - requiredFiniteNumber, - requiredString -} from './orcad-migration-manifest-fields' -import { - assertOrcadMigrationDormantStateReferences, - parseOrcadMigrationDormantState -} from './orcad-migration-dormant-state-validation' - -export const ORCAD_MIGRATION_MANIFEST_VERSION = 1 as const -export type OrcadMigrationManifestVersion = typeof ORCAD_MIGRATION_MANIFEST_VERSION -export const MAX_ORCAD_MIGRATION_MANIFEST_BYTES = 768 * 1024 -export const MAX_ORCAD_MIGRATION_REPOSITORIES = 1_024 -export const MAX_ORCAD_MIGRATION_PROJECT_GROUPS = 4_096 -export const MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES = 16_384 -export const MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS = 64 - -export function parseOrcadMigrationManifest(value: unknown): OrcadMigrationManifest { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_invalid') - } - if (value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_manifest_version_unsupported') - } - const migrationId = requiredString(value.migrationId, 'migrationId') - const createdAt = requiredDate(value.createdAt, 'createdAt') - const source = parseSource(value.source) - const payload = parsePayload(value.payload) - const manifestSha256 = requiredString(value.manifestSha256, 'manifestSha256') - if (!/^[a-f0-9]{64}$/.test(manifestSha256)) { - throw new Error('orcad_migration_manifest_digest_invalid') - } - const manifest: OrcadMigrationManifest = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt, - source, - payload, - ...(value.destinationEnvironmentId === undefined - ? {} - : { - destinationEnvironmentId: requiredString( - value.destinationEnvironmentId, - 'destinationEnvironmentId' - ) - }), - manifestSha256 - } - const bytes = new TextEncoder().encode(JSON.stringify(manifest)).byteLength - if (bytes > MAX_ORCAD_MIGRATION_MANIFEST_BYTES) { - throw new Error('orcad_migration_manifest_too_large') - } - return manifest -} - -export function normalizeOrcadMigrationImportReceipts( - value: unknown -): OrcadMigrationImportReceipt[] { - if (!Array.isArray(value)) { - return [] - } - const receipts: OrcadMigrationImportReceipt[] = [] - const seen = new Set() - for (let index = value.length - 1; index >= 0; index--) { - try { - const receipt = parseReceipt(value[index]) - if (!seen.has(receipt.migrationId)) { - seen.add(receipt.migrationId) - receipts.push(receipt) - if (receipts.length === MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS) { - break - } - } - } catch { - // Invalid receipts cannot prove an import and are dropped fail-closed. - } - } - // Keep persisted receipts readable in the documented Node 18 rollback slot. - return receipts.reduceRight((reversed, receipt) => { - reversed.push(receipt) - return reversed - }, []) -} - -function parsePayload(value: unknown): OrcadMigrationCatalogPayload { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_payload_invalid') - } - const repositories = boundedArray( - value.repositories, - MAX_ORCAD_MIGRATION_REPOSITORIES, - parseRepository, - 'repositories' - ) - const projectGroups = boundedArray( - value.projectGroups, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - parseProjectGroup, - 'projectGroups' - ) - const folderWorkspaces = boundedArray( - value.folderWorkspaces, - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - parseFolderWorkspace, - 'folderWorkspaces' - ) - assertUniqueIds(repositories, 'repositories') - assertUniqueIds(projectGroups, 'projectGroups') - assertUniqueIds(folderWorkspaces, 'folderWorkspaces') - const dormantState = - value.dormantState === undefined - ? undefined - : parseOrcadMigrationDormantState(value.dormantState) - if (dormantState) { - assertOrcadMigrationDormantStateReferences({ - dormantState, - repositoryIds: new Set(repositories.map((repo) => repo.id)), - folderWorkspaceIds: new Set(folderWorkspaces.map((workspace) => workspace.id)) - }) - } - return { - repositories, - projectGroups, - folderWorkspaces, - ...(dormantState ? { dormantState } : {}) - } -} - -function parseSource(value: unknown): OrcadMigrationManifestSource { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_source_invalid') - } - const generation = value.sshTargetGeneration - if (generation !== null && (!Number.isSafeInteger(generation) || Number(generation) < 1)) { - throw new Error('orcad_migration_manifest_source_generation_invalid') - } - return { - sshTargetId: requiredString(value.sshTargetId, 'source.sshTargetId'), - sshTargetGeneration: generation === null ? null : Number(generation), - targetLabel: requiredString(value.targetLabel, 'source.targetLabel') - } -} - -function parseRepository(value: unknown): Repo { - const copy = structuredClone(value) - if (!isMigratedRepository(copy)) { - throw new Error('orcad_migration_manifest_repository_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedRepository(value: unknown): value is Repo { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_repository_invalid') - } - requiredString(value.id, 'repository.id') - requiredString(value.path, 'repository.path') - requiredString(value.displayName, 'repository.displayName') - requiredString(value.badgeColor, 'repository.badgeColor') - requiredFiniteNumber(value.addedAt, 'repository.addedAt') - if (value.kind !== undefined && value.kind !== 'git' && value.kind !== 'folder') { - throw new Error('orcad_migration_manifest_repository_kind_invalid') - } - return true -} - -function parseProjectGroup(value: unknown): ProjectGroup { - const copy = structuredClone(value) - if (!isMigratedProjectGroup(copy)) { - throw new Error('orcad_migration_manifest_project_group_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedProjectGroup(value: unknown): value is ProjectGroup { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_project_group_invalid') - } - requiredString(value.id, 'projectGroup.id') - requiredString(value.name, 'projectGroup.name') - requiredFiniteNumber(value.tabOrder, 'projectGroup.tabOrder') - requiredFiniteNumber(value.createdAt, 'projectGroup.createdAt') - requiredFiniteNumber(value.updatedAt, 'projectGroup.updatedAt') - if (value.parentPath !== null && typeof value.parentPath !== 'string') { - throw new Error('orcad_migration_manifest_project_group_parent_path_invalid') - } - if (value.parentGroupId !== null && typeof value.parentGroupId !== 'string') { - throw new Error('orcad_migration_manifest_project_group_parent_invalid') - } - if (typeof value.isCollapsed !== 'boolean') { - throw new Error('orcad_migration_manifest_project_group_collapsed_invalid') - } - if (value.color !== null && typeof value.color !== 'string') { - throw new Error('orcad_migration_manifest_project_group_color_invalid') - } - if (!['manual', 'folder-scan', 'migration'].includes(String(value.createdFrom))) { - throw new Error('orcad_migration_manifest_project_group_origin_invalid') - } - return true -} - -function parseFolderWorkspace(value: unknown): FolderWorkspace { - const copy = structuredClone(value) - if (!isMigratedFolderWorkspace(copy)) { - throw new Error('orcad_migration_manifest_folder_workspace_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedFolderWorkspace(value: unknown): value is FolderWorkspace { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_folder_workspace_invalid') - } - requiredString(value.id, 'folderWorkspace.id') - requiredString(value.projectGroupId, 'folderWorkspace.projectGroupId') - requiredString(value.name, 'folderWorkspace.name') - requiredString(value.folderPath, 'folderWorkspace.folderPath') - requiredFiniteNumber(value.sortOrder, 'folderWorkspace.sortOrder') - requiredFiniteNumber(value.lastActivityAt, 'folderWorkspace.lastActivityAt') - requiredFiniteNumber(value.createdAt, 'folderWorkspace.createdAt') - requiredFiniteNumber(value.updatedAt, 'folderWorkspace.updatedAt') - for (const key of ['isArchived', 'isUnread', 'isPinned'] as const) { - if (typeof value[key] !== 'boolean') { - throw new Error(`orcad_migration_manifest_folder_workspace_${key}_invalid`) - } - } - return true -} - -function parseReceipt(value: unknown): OrcadMigrationImportReceipt { - if (!isRecord(value) || value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_receipt_invalid') - } - const manifestSha256 = requiredString(value.manifestSha256, 'receipt.manifestSha256') - if (!/^[a-f0-9]{64}$/.test(manifestSha256)) { - throw new Error('orcad_migration_receipt_digest_invalid') - } - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: requiredString(value.migrationId, 'receipt.migrationId'), - manifestSha256, - source: parseSource(value.source), - importedAt: requiredDate(value.importedAt, 'receipt.importedAt'), - repositoryIds: boundedStringArray( - value.repositoryIds, - MAX_ORCAD_MIGRATION_REPOSITORIES, - 'receipt.repositoryIds' - ), - projectGroupIds: boundedStringArray( - value.projectGroupIds, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - 'receipt.projectGroupIds' - ), - folderWorkspaceIds: boundedStringArray( - value.folderWorkspaceIds, - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - 'receipt.folderWorkspaceIds' - ) - } -} diff --git a/src/shared/orcad-migration-manifest.test.ts b/src/shared/orcad-migration-manifest.test.ts deleted file mode 100644 index f677d54ff22..00000000000 --- a/src/shared/orcad-migration-manifest.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS, - MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS, - MAX_ORCAD_MIGRATION_MANIFEST_BYTES, - normalizeOrcadMigrationImportReceipts, - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest, - serializeOrcadMigrationValue, - type OrcadMigrationImportReceipt -} from './orcad-migration-manifest' - -function receipt(index: number, overrides: Partial = {}) { - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: `migration-${index}`, - manifestSha256: index.toString(16).padStart(64, '0'), - source: { - sshTargetId: 'ssh-prod', - sshTargetGeneration: 1, - targetLabel: 'Production' - }, - importedAt: new Date(index * 1_000).toISOString(), - repositoryIds: [], - projectGroupIds: [], - folderWorkspaceIds: [], - ...overrides - } -} - -describe('orcad migration manifest', () => { - it('serializes object keys deterministically without locale ordering', () => { - expect( - serializeOrcadMigrationValue({ z: 1, Z: 2, nested: { ä: 3, a: 4 }, omitted: undefined }) - ).toBe('{"Z":2,"nested":{"a":4,"ä":3},"z":1}') - }) - - it('keeps the newest bounded set of valid unique receipts', () => { - const candidates: unknown[] = Array.from( - { length: MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS + 2 }, - (_, index) => receipt(index) - ) - candidates.push(receipt(10, { source: { ...receipt(10).source, targetLabel: 'Newest' } })) - candidates.push({ invalid: true }) - - const normalized = normalizeOrcadMigrationImportReceipts(candidates) - - expect(normalized).toHaveLength(MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS) - expect(normalized.some((entry) => entry.migrationId === 'migration-0')).toBe(false) - expect( - normalized.find((entry) => entry.migrationId === 'migration-10')?.source.targetLabel - ).toBe('Newest') - }) - - it('normalizes receipts without Node 20-only array methods', () => { - const prototype: object = Array.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'toReversed') - try { - Object.defineProperty(prototype, 'toReversed', { - configurable: true, - value: undefined, - writable: true - }) - expect(normalizeOrcadMigrationImportReceipts([receipt(1), receipt(2)])).toHaveLength(2) - } finally { - if (descriptor) { - Object.defineProperty(prototype, 'toReversed', descriptor) - } else { - Reflect.deleteProperty(prototype, 'toReversed') - } - } - }) - - it('drops a receipt with duplicate catalog identities', () => { - expect( - normalizeOrcadMigrationImportReceipts([receipt(1, { repositoryIds: ['repo-1', 'repo-1'] })]) - ).toEqual([]) - }) - - it('accepts bounded dormant state and rejects rows outside the catalog scope', () => { - const candidate = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-dormant', - createdAt: '2026-08-30T12:00:00.000Z', - source: receipt(1).source, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [ - { - sourceKey: 'repo-1::/srv/worktree', - worktreeId: 'repo-1::/srv/worktree', - meta: { - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 1, - hostId: 'local' - } - } - ], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - workspaceSession: { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - 'repo-1::/srv/worktree': [ - { - id: 'tab-dormant', - ptyId: null, - worktreeId: 'repo-1::/srv/worktree', - title: 'Dormant terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - } - }, - manifestSha256: 'a'.repeat(64) - } - - expect(parseOrcadMigrationManifest(candidate).payload.dormantState?.worktreeMeta).toHaveLength( - 1 - ) - expect( - parseOrcadMigrationManifest(candidate).payload.dormantState?.workspaceSession?.tabsByWorktree - ).toHaveProperty('repo-1::/srv/worktree') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: [ - { - ...candidate.payload.dormantState.worktreeMeta[0], - worktreeId: 'repo-other::/srv/worktree' - } - ] - } - } - }) - ).toThrow('orcad_migration_dormant_worktree_meta_scope_invalid') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - workspaceSession: { - ...candidate.payload.dormantState.workspaceSession, - tabsByWorktree: { - 'repo-1::/srv/worktree': [ - { - ...candidate.payload.dormantState.workspaceSession.tabsByWorktree[ - 'repo-1::/srv/worktree' - ][0], - ptyId: 'pty-live' - } - ] - } - } - } - } - }) - ).toThrow('orcad_migration_dormant_workspace_session_pty_invalid') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: Array.from( - { length: MAX_ORCAD_MIGRATION_DORMANT_ROWS + 1 }, - () => candidate.payload.dormantState.worktreeMeta[0] - ) - } - } - }) - ).toThrow('orcad_migration_dormant_worktree_meta_too_many') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - retiredWorktreeNamespaces: Array.from( - { length: MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES + 1 }, - () => ({ - sourceNamespaceKeys: ['ssh:source:/srv/orca-worktrees'], - namespaceKey: 'local:/srv/orca-worktrees', - registry: { exhaustedTiers: 0, names: ['nautilus'] } - }) - ) - } - } - }) - ).toThrow('orcad_migration_dormant_retirement_namespaces_too_many') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: [ - { - ...candidate.payload.dormantState.worktreeMeta[0], - meta: { - ...candidate.payload.dormantState.worktreeMeta[0].meta, - comment: 'x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) - } - } - ] - } - } - }) - ).toThrow('orcad_migration_manifest_too_large') - }) - - it('accepts only destination-owned disabled automations with final run history', () => { - const automation = { - id: 'automation-1', - name: 'Nightly checks', - prompt: 'Run tests', - precheck: null, - agentId: 'codex', - runContext: { - kind: 'workspace-run', - projectId: 'repo:repo-1', - hostId: 'local', - projectHostSetupId: 'repo-1', - repoId: 'repo-1', - path: '/srv/repo-1' - }, - sourceContext: null, - projectId: 'repo-1', - executionTargetType: 'local', - executionTargetId: 'local', - schedulerOwner: 'remote_host_service', - workspaceMode: 'new_per_run', - workspaceId: null, - baseBranch: 'main', - reuseSession: false, - timezone: 'UTC', - rrule: 'FREQ=DAILY', - dtstart: 1, - enabled: false, - nextRunAt: 2, - missedRunPolicy: 'run_once_within_grace', - missedRunGraceMinutes: 60, - createdAt: 1, - updatedAt: 2 - } - const run = { - id: 'run-1', - automationId: automation.id, - runContext: automation.runContext, - sourceContext: null, - title: 'Nightly checks run 1', - scheduledFor: 1, - status: 'completed', - trigger: 'scheduled', - workspaceId: null, - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: 'tab-history', - terminalPaneKey: null, - terminalPtyId: 'pty-history', - outputSnapshot: { - format: 'plain_text', - content: 'all green', - capturedAt: 2, - truncated: false - }, - precheckResult: null, - usage: null, - error: null, - startedAt: 1, - dispatchedAt: 1, - createdAt: 1 - } - const candidate = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-automation', - createdAt: '2026-08-30T12:00:00.000Z', - source: receipt(1).source, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - automations: [automation], - automationRuns: [run] - } - }, - manifestSha256: 'a'.repeat(64) - } - - expect( - parseOrcadMigrationManifest(candidate).payload.dormantState?.automationRuns?.[0] - ?.outputSnapshot?.content - ).toBe('all green') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - automations: [{ ...automation, enabled: true }] - } - } - }) - ).toThrow('orcad_migration_dormant_automation_owner_invalid') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - automationRuns: [{ ...run, status: 'dispatching' }] - } - } - }) - ).toThrow('orcad_migration_dormant_automation_run_status_invalid') - }) -}) diff --git a/src/shared/orcad-migration-manifest.ts b/src/shared/orcad-migration-manifest.ts deleted file mode 100644 index 9a0d1eab1ec..00000000000 --- a/src/shared/orcad-migration-manifest.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { Automation, AutomationRun } from './automations-types' -import type { OrcadMigrationManifestVersion } from './orcad-migration-manifest-validation' -import type { ProjectGroup } from './project-group-types' -import type { Repo } from './repo-types' -import type { SparsePreset } from './worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' -import type { WorktreeMeta } from './worktree/meta-types' -import type { RetiredNameRegistry } from './worktree/retired-name-registry' -import type { WorkspaceSessionState } from './workspace-session-state-types' -import type { OrcadMigrationClientStatePayload } from './orcad-migration-client-state' -import type { - OrcadMigrationSnapshotUploadState, - OrcadMigrationTerminalScrollbackSnapshot -} from './orcad-migration-scrollback' - -export { - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS, - MAX_ORCAD_MIGRATION_MANIFEST_BYTES, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - MAX_ORCAD_MIGRATION_REPOSITORIES, - normalizeOrcadMigrationImportReceipts, - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest-validation' -export { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS, - ORCAD_MIGRATION_DORMANT_STATE_VERSION -} from './orcad-migration-dormant-state-validation' -export { - MAX_ORCAD_MIGRATION_STAGED_CATALOGS, - normalizeOrcadMigrationStagedCatalogs -} from './orcad-migration-staged-catalog-validation' - -export type OrcadMigrationManifestSource = { - sshTargetId: string - sshTargetGeneration: number | null - targetLabel: string -} - -export type OrcadMigrationCatalogPayload = { - repositories: Repo[] - projectGroups: ProjectGroup[] - folderWorkspaces: FolderWorkspace[] - dormantState?: OrcadMigrationDormantStatePayload -} - -export type OrcadMigrationDormantWorktreeMeta = { - sourceKey: string - worktreeId: string - meta: WorktreeMeta -} - -export type OrcadMigrationDormantWorktreeLineage = { - sourceKey: string - worktreeId: string - lineage: WorktreeLineage -} - -export type OrcadMigrationDormantWorkspaceLineage = { - sourceKey: string - childWorkspaceKey: string - lineage: WorkspaceLineage -} - -export type OrcadMigrationDormantRetiredNames = { - repoId: string - registry: RetiredNameRegistry -} - -export type OrcadMigrationDormantRetirementNamespace = { - sourceNamespaceKeys: string[] - namespaceKey: string - registry: RetiredNameRegistry -} - -export type OrcadMigrationDormantStatePayload = { - version: 1 - worktreeMeta: OrcadMigrationDormantWorktreeMeta[] - worktreeLineage: OrcadMigrationDormantWorktreeLineage[] - workspaceLineage: OrcadMigrationDormantWorkspaceLineage[] - sparsePresets: SparsePreset[] - retiredWorktreeNames: OrcadMigrationDormantRetiredNames[] - retiredWorktreeNamespaces: OrcadMigrationDormantRetirementNamespace[] - workspaceSession?: WorkspaceSessionState - terminalScrollbackSnapshots?: OrcadMigrationTerminalScrollbackSnapshot[] - automations?: Automation[] - automationRuns?: AutomationRun[] - /** Client-owned durable intent projected to the destination authority. */ - clientState?: OrcadMigrationClientStatePayload -} - -export type OrcadMigrationManifest = { - version: OrcadMigrationManifestVersion - migrationId: string - createdAt: string - source: OrcadMigrationManifestSource - payload: OrcadMigrationCatalogPayload - /** Destination runtime identity used to re-key desktop routing after commit. */ - destinationEnvironmentId?: string - manifestSha256: string -} - -export type OrcadMigrationImportReceipt = { - version: OrcadMigrationManifestVersion - migrationId: string - manifestSha256: string - source: OrcadMigrationManifestSource - importedAt: string - repositoryIds: string[] - projectGroupIds: string[] - folderWorkspaceIds: string[] -} - -export type OrcadMigrationImportResult = { - status: 'imported' | 'already-imported' - receipt: OrcadMigrationImportReceipt -} - -export type OrcadMigrationStagedCatalog = { - version: OrcadMigrationManifestVersion - manifest: OrcadMigrationManifest - stagedAt: string -} - -export type OrcadMigrationCatalogState = - | { - state: 'absent' - migrationId: string - manifestSha256: string - } - | { - state: 'staged' - migrationId: string - manifestSha256: string - stagedAt: string - snapshotUploads?: OrcadMigrationSnapshotUploadState[] - } - | { - state: 'committed' - migrationId: string - manifestSha256: string - receipt: OrcadMigrationImportReceipt - } - -export type OrcadMigrationCatalogAbortResult = OrcadMigrationCatalogState & { - aborted: boolean - durableAbsent?: true -} - -export function orcadMigrationManifestHashInput( - manifest: Omit -): string { - return serializeOrcadMigrationValue(manifest) -} - -export function serializeOrcadMigrationValue(value: unknown): string { - return JSON.stringify(canonicalizeJsonValue(value)) -} - -function canonicalizeJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalizeJsonValue) - } - if (!isRecord(value)) { - return value - } - return Object.fromEntries( - Object.entries(value) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) - .map(([key, entry]) => [key, canonicalizeJsonValue(entry)]) - ) -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-preflight.ts b/src/shared/orcad-migration-preflight.ts deleted file mode 100644 index 31b1687329c..00000000000 --- a/src/shared/orcad-migration-preflight.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { Repo } from './repo-types' -import type { SavedPortForward, SshRemotePtyLease, SshTarget } from './ssh-types' - -export type OrcadMigrationBlockerCategory = - | 'registration' - | 'exclusive-ownership' - | 'drainable-static-state' - | 'live-or-unverifiable' - | 'client-owned-state' - -export const ORCAD_MIGRATION_DEPENDENCY_KINDS = [ - 'saved-port-forward', - 'terminal-lease', - 'terminal-recovery', - 'workspace-session', - 'worktree-metadata', - 'worktree-lineage', - 'workspace-lineage', - 'sparse-preset', - 'retired-worktree-name', - 'automation', - 'automation-run', - 'mobile-tab-selection', - 'ui-routing' -] as const - -export type OrcadMigrationDependencyKind = (typeof ORCAD_MIGRATION_DEPENDENCY_KINDS)[number] - -export type OrcadMigrationDependency = { - kind: OrcadMigrationDependencyKind - count: number - /** A bounded sample of what holds the reference, so the user can find and clear it. */ - names?: string[] -} - -export type OrcadMigrationRepository = Pick -export type OrcadMigrationFolderWorkspace = Pick -export type OrcadMigrationTerminalLease = Pick< - SshRemotePtyLease, - 'ptyId' | 'worktreeId' | 'tabId' | 'leafId' | 'state' | 'updatedAt' -> - -export type OrcadMigrationBlocker = - | { - code: 'orcad_migration_target_not_found' - category: 'registration' - } - | { - code: 'orcad_migration_target_owned' - category: 'exclusive-ownership' - owner: NonNullable - } - | { - code: 'orcad_migration_direct_ssh_repositories' - category: 'drainable-static-state' - repositories: OrcadMigrationRepository[] - } - | { - code: 'orcad_migration_direct_ssh_folder_workspaces' - category: 'drainable-static-state' - folderWorkspaces: OrcadMigrationFolderWorkspace[] - } - | { - code: 'orcad_migration_direct_ssh_terminal_leases' - category: 'live-or-unverifiable' - terminalLeases: OrcadMigrationTerminalLease[] - } - | { - code: 'orcad_migration_saved_port_forwards' - category: 'client-owned-state' - portForwards: SavedPortForward[] - } - | { - code: 'orcad_migration_dependent_state' - category: 'client-owned-state' - dependencies: OrcadMigrationDependency[] - } - | { - code: 'orcad_migration_dependency_unverifiable' - category: 'live-or-unverifiable' - sources: OrcadMigrationDependencyKind[] - } - -export type OrcadMigrationPreflight = { - targetId: string - targetLabel: string | null - claimable: boolean - blockers: OrcadMigrationBlocker[] -} diff --git a/src/shared/orcad-migration-scrollback.test.ts b/src/shared/orcad-migration-scrollback.test.ts deleted file mode 100644 index 012b8538ff3..00000000000 --- a/src/shared/orcad-migration-scrollback.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - assertOrcadMigrationScrollbackReferences, - MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS, - OrcadMigrationSnapshotChunkRequestSchema, - parseOrcadMigrationSnapshotChunkResult, - parseOrcadMigrationTerminalScrollbackSnapshots, - type OrcadMigrationSnapshotChunkRequest, - type OrcadMigrationTerminalScrollbackSnapshot -} from './orcad-migration-scrollback' -import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from './terminal-scrollback-limits' - -const DIGEST = 'a'.repeat(64) - -describe('orcad migration scrollback wire validation', () => { - it('accepts only canonical non-empty bounded base64 chunks', () => { - const request = chunkRequest('YQ==') - expect(OrcadMigrationSnapshotChunkRequestSchema.safeParse(request).success).toBe(true) - for (const bytesBase64 of ['', 'YQ', 'YQ=', 'A===', '***=']) { - expect( - OrcadMigrationSnapshotChunkRequestSchema.safeParse({ ...request, bytesBase64 }).success - ).toBe(false) - } - }) - - it('requires an acknowledgment for exactly the decoded chunk length', () => { - const request = chunkRequest(Buffer.from('abc').toString('base64'), 7) - expect( - parseOrcadMigrationSnapshotChunkResult( - { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset: 10 - }, - request - ).acknowledgedOffset - ).toBe(10) - for (const acknowledgedOffset of [7, 9, 11]) { - expect(() => - parseOrcadMigrationSnapshotChunkResult( - { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset - }, - request - ) - ).toThrow('orcad_migration_snapshot_chunk_result_invalid') - } - }) - - it('requires unique refs and tab/leaf identities', () => { - const first = descriptor(1) - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([first, { ...descriptor(2), ref: first.ref }]) - ).toThrow('orcad_migration_dormant_scrollback_snapshot_ref_duplicate') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([ - first, - { ...descriptor(2), tabId: first.tabId, leafId: first.leafId } - ]) - ).toThrow('orcad_migration_dormant_scrollback_snapshot_leaf_duplicate') - }) - - it('enforces descriptor count, per-file, and aggregate limits', () => { - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots( - Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + 1 }, (_, index) => - descriptor(index + 1) - ) - ) - ).toThrow('orcad_migration_dormant_scrollback_snapshots_too_many') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([ - { ...descriptor(1), byteLength: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1 } - ]) - ).toThrow('orcad_migration_dormant_scrollback_size_invalid') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots( - Array.from({ length: 52 }, (_, index) => ({ - ...descriptor(index + 1), - byteLength: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - })) - ) - ).toThrow('orcad_migration_dormant_scrollback_snapshots_too_large') - }) - - it('requires one exact descriptor for every published session ref', () => { - const session = getDefaultWorkspaceSession() - const first = descriptor(1) - session.terminalLayoutsByTabId = { - [first.tabId]: { - root: { type: 'leaf', leafId: first.leafId }, - activeLeafId: first.leafId, - expandedLeafId: null, - scrollbackRefsByLeafId: { [first.leafId]: first.ref } - } - } - expect(() => assertOrcadMigrationScrollbackReferences(session, [first])).not.toThrow() - expect(() => assertOrcadMigrationScrollbackReferences(session, [])).toThrow( - 'orcad_migration_dormant_scrollback_reference_invalid' - ) - expect(() => - assertOrcadMigrationScrollbackReferences(session, [{ ...first, ref: descriptor(2).ref }]) - ).toThrow('orcad_migration_dormant_scrollback_reference_invalid') - expect(() => assertOrcadMigrationScrollbackReferences(session, [first, descriptor(2)])).toThrow( - 'orcad_migration_dormant_scrollback_reference_invalid' - ) - }) -}) - -function chunkRequest(bytesBase64: string, offset = 0): OrcadMigrationSnapshotChunkRequest { - return { - migrationId: 'migration-1', - manifestSha256: DIGEST, - ref: `v1-${'1'.repeat(32)}`, - offset, - bytesBase64 - } -} - -function descriptor(index: number): OrcadMigrationTerminalScrollbackSnapshot { - const identity = index.toString(16).padStart(32, '0') - return { - tabId: `tab-${index}`, - leafId: `leaf-${index}`, - ref: `v1-${identity}`, - sha256: index.toString(16).padStart(64, '0'), - byteLength: 1 - } -} diff --git a/src/shared/orcad-migration-scrollback.ts b/src/shared/orcad-migration-scrollback.ts deleted file mode 100644 index 2b09e98b92d..00000000000 --- a/src/shared/orcad-migration-scrollback.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { z } from 'zod' -import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from './terminal-scrollback-limits' -import type { WorkspaceSessionState } from './workspace-session-state-types' -import { - assertUnique, - boundedArray, - requiredRecord, - requiredString -} from './orcad-migration-dormant-value-validation' - -export const MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS = 512 -export const MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES = 256 * 1024 * 1024 -export const ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES = 128 * 1024 - -const Identity = z.string().min(1).max(256) -const Digest = z.string().regex(/^[a-f0-9]{64}$/) -const SnapshotRef = z.string().regex(/^v1-[a-f0-9]{32}$/) -const CanonicalBase64Chunk = z - .string() - .max(Math.ceil(ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES / 3) * 4) - .refine((value) => { - try { - return value.length > 0 && Buffer.from(value, 'base64').toString('base64') === value - } catch { - return false - } - }) - -export type OrcadMigrationTerminalScrollbackSnapshot = { - tabId: string - leafId: string - ref: string - sha256: string - byteLength: number -} - -export const OrcadMigrationSnapshotChunkRequestSchema = z - .object({ - migrationId: Identity, - manifestSha256: Digest, - ref: SnapshotRef, - offset: z.number().int().nonnegative().max(MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES), - bytesBase64: CanonicalBase64Chunk - }) - .strict() - -export type OrcadMigrationSnapshotChunkRequest = z.infer< - typeof OrcadMigrationSnapshotChunkRequestSchema -> - -export type OrcadMigrationSnapshotChunkResult = { - migrationId: string - manifestSha256: string - ref: string - acknowledgedOffset: number -} - -export type OrcadMigrationSnapshotUploadState = OrcadMigrationTerminalScrollbackSnapshot & { - receivedBytes: number -} - -export function parseOrcadMigrationSnapshotUploadStates( - value: unknown, - snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] -): OrcadMigrationSnapshotUploadState[] | undefined { - if (snapshots.length === 0) { - return undefined - } - if (!Array.isArray(value) || value.length !== snapshots.length) { - throw new Error('orcad_migration_snapshot_transfer_unsupported') - } - return snapshots.map((expected, index) => { - const record = requiredRecord(value[index], 'orcad_migration_snapshot_state_invalid') - if ( - record.tabId !== expected.tabId || - record.leafId !== expected.leafId || - record.ref !== expected.ref || - record.sha256 !== expected.sha256 || - record.byteLength !== expected.byteLength || - !Number.isSafeInteger(record.receivedBytes) || - Number(record.receivedBytes) < 0 || - Number(record.receivedBytes) > expected.byteLength - ) { - throw new Error('orcad_migration_snapshot_state_invalid') - } - return { ...expected, receivedBytes: Number(record.receivedBytes) } - }) -} - -export function parseOrcadMigrationSnapshotChunkResult( - value: unknown, - request: OrcadMigrationSnapshotChunkRequest -): OrcadMigrationSnapshotChunkResult { - const record = requiredRecord(value, 'orcad_migration_snapshot_chunk_result_invalid') - const acknowledgedOffset = - request.offset + decodeOrcadMigrationSnapshotChunk(request.bytesBase64).length - if ( - record.migrationId !== request.migrationId || - record.manifestSha256 !== request.manifestSha256 || - record.ref !== request.ref || - !Number.isSafeInteger(record.acknowledgedOffset) || - Number(record.acknowledgedOffset) !== acknowledgedOffset - ) { - throw new Error('orcad_migration_snapshot_chunk_result_invalid') - } - return { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset - } -} - -export function parseOrcadMigrationTerminalScrollbackSnapshots( - value: unknown -): OrcadMigrationTerminalScrollbackSnapshot[] { - const snapshots = boundedArray( - value, - parseSnapshot, - 'scrollback_snapshots', - MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS - ) - assertUnique(snapshots, (entry) => entry.ref, 'scrollback_snapshot_ref') - assertUnique(snapshots, (entry) => `${entry.tabId}\0${entry.leafId}`, 'scrollback_snapshot_leaf') - if ( - snapshots.reduce((total, entry) => total + entry.byteLength, 0) > - MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES - ) { - throw new Error('orcad_migration_dormant_scrollback_snapshots_too_large') - } - return snapshots -} - -export function assertOrcadMigrationScrollbackReferences( - session: WorkspaceSessionState | undefined, - snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] -): void { - const expected = new Map(snapshots.map((entry) => [`${entry.tabId}\0${entry.leafId}`, entry])) - let referenceCount = 0 - for (const [tabId, layout] of Object.entries(session?.terminalLayoutsByTabId ?? {})) { - for (const [leafId, ref] of Object.entries(layout.scrollbackRefsByLeafId ?? {})) { - const descriptor = expected.get(`${tabId}\0${leafId}`) - if (!descriptor || descriptor.ref !== ref) { - throw new Error('orcad_migration_dormant_scrollback_reference_invalid') - } - referenceCount += 1 - } - } - if (referenceCount !== snapshots.length) { - throw new Error('orcad_migration_dormant_scrollback_reference_invalid') - } -} - -export function decodeOrcadMigrationSnapshotChunk(bytesBase64: string): Buffer { - const bytes = Buffer.from(bytesBase64, 'base64') - if ( - bytes.length === 0 || - bytes.length > ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES || - bytes.toString('base64') !== bytesBase64 - ) { - throw new Error('orcad_migration_snapshot_chunk_invalid') - } - return bytes -} - -function parseSnapshot(value: unknown): OrcadMigrationTerminalScrollbackSnapshot { - const record = requiredRecord(value, 'orcad_migration_dormant_scrollback_snapshot_invalid') - const ref = requiredString(record.ref, 'orcad_migration_dormant_scrollback_ref_invalid') - const sha256 = requiredString(record.sha256, 'orcad_migration_dormant_scrollback_digest_invalid') - if (!SnapshotRef.safeParse(ref).success || !Digest.safeParse(sha256).success) { - throw new Error('orcad_migration_dormant_scrollback_identity_invalid') - } - if ( - !Number.isSafeInteger(record.byteLength) || - Number(record.byteLength) < 1 || - Number(record.byteLength) > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - ) { - throw new Error('orcad_migration_dormant_scrollback_size_invalid') - } - return { - tabId: requiredString(record.tabId, 'orcad_migration_dormant_scrollback_tab_invalid'), - leafId: requiredString(record.leafId, 'orcad_migration_dormant_scrollback_leaf_invalid'), - ref, - sha256, - byteLength: Number(record.byteLength) - } -} diff --git a/src/shared/orcad-migration-staged-catalog-validation.test.ts b/src/shared/orcad-migration-staged-catalog-validation.test.ts deleted file mode 100644 index 9219d47a3c2..00000000000 --- a/src/shared/orcad-migration-staged-catalog-validation.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { normalizeOrcadMigrationStagedCatalogs } from './orcad-migration-staged-catalog-validation' -import { ORCAD_MIGRATION_MANIFEST_VERSION } from './orcad-migration-manifest-validation' - -function staged(migrationId: string) { - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - stagedAt: '2026-08-30T12:00:00.000Z', - manifest: { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt: '2026-08-30T12:00:00.000Z', - source: { sshTargetId: 'ssh-prod', sshTargetGeneration: 1, targetLabel: 'Production' }, - payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, - manifestSha256: 'a'.repeat(64) - } - } -} - -describe('orcad staged catalog normalization', () => { - it('recovers without Node 20-only array methods', () => { - const prototype: object = Array.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'toReversed') - try { - Object.defineProperty(prototype, 'toReversed', { - configurable: true, - value: undefined, - writable: true - }) - expect(normalizeOrcadMigrationStagedCatalogs([staged('migration-1')])).toEqual([ - expect.objectContaining({ - manifest: expect.objectContaining({ migrationId: 'migration-1' }) - }) - ]) - } finally { - if (descriptor) { - Object.defineProperty(prototype, 'toReversed', descriptor) - } else { - Reflect.deleteProperty(prototype, 'toReversed') - } - } - }) -}) diff --git a/src/shared/orcad-migration-staged-catalog-validation.ts b/src/shared/orcad-migration-staged-catalog-validation.ts deleted file mode 100644 index f32dafec231..00000000000 --- a/src/shared/orcad-migration-staged-catalog-validation.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { OrcadMigrationStagedCatalog } from './orcad-migration-manifest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest-validation' - -export const MAX_ORCAD_MIGRATION_STAGED_CATALOGS = 4 - -export function normalizeOrcadMigrationStagedCatalogs( - value: unknown -): OrcadMigrationStagedCatalog[] { - if (!Array.isArray(value)) { - return [] - } - const staged: OrcadMigrationStagedCatalog[] = [] - const seen = new Set() - for (let index = value.length - 1; index >= 0; index--) { - try { - const entry = parseStagedCatalog(value[index]) - if (!seen.has(entry.manifest.migrationId)) { - seen.add(entry.manifest.migrationId) - staged.push(entry) - if (staged.length === MAX_ORCAD_MIGRATION_STAGED_CATALOGS) { - break - } - } - } catch { - // Invalid staging cannot be trusted as fencing evidence, so it is dropped fail-closed. - } - } - // Keep the migration journal runnable in the documented Node 18 rollback slot. - return staged.reduceRight((reversed, entry) => { - reversed.push(entry) - return reversed - }, []) -} - -function parseStagedCatalog(value: unknown): OrcadMigrationStagedCatalog { - if (!isRecord(value) || value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_staged_catalog_invalid') - } - const stagedAt = typeof value.stagedAt === 'string' ? value.stagedAt : '' - if (!Number.isFinite(Date.parse(stagedAt))) { - throw new Error('orcad_migration_staged_at_invalid') - } - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - manifest: parseOrcadMigrationManifest(value.manifest), - stagedAt - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-ssh-provisioning.ts b/src/shared/orcad-ssh-provisioning.ts deleted file mode 100644 index 11798f75e4c..00000000000 --- a/src/shared/orcad-ssh-provisioning.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { OrcadManagedDeployResult } from './orcad-managed-runtime' -import type { SshRepoReadoption, SshTargetCreateInput } from './ssh-types' - -export type OrcadSshProvisioningIntent = Readonly<{ - requestId: string - name: string -}> - -export type OrcadSshProvisioningRequest = OrcadSshProvisioningIntent & { - target: SshTargetCreateInput -} - -export type OrcadSshPendingProvisioning = OrcadSshProvisioningIntent & { - sshTargetId: string -} - -export type OrcadSshProvisioningResult = OrcadSshPendingProvisioning & { - repoReadoptions: SshRepoReadoption[] - result: OrcadManagedDeployResult | { outcome: 'pending'; reason: string } -} diff --git a/src/shared/orcad-stop-request.ts b/src/shared/orcad-stop-request.ts deleted file mode 100644 index 9b3d89aba2b..00000000000 --- a/src/shared/orcad-stop-request.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Stop requests for a running orcad, as files beside the process they address. - * - * Why files and not signals: a PID can be reused once its process exits, so a signal sent - * from a stale record can stop an unrelated process. A file in the slot or data root reaches - * only the orcad that watches it. - */ -import { z } from 'zod' - -/** Plain request in the slot directory: the same graceful stop as SIGTERM. */ -export const ORCAD_STOP_REQUEST_FILENAME = '.orcad-stop-request' -/** Prefix of an instance-bound request in the data root; older listeners never match it. */ -export const ORCAD_MANAGED_STOP_REQUEST_PREFIX = '.orcad-managed-stop-request' -export const ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES = 32 * 1024 -export const ORCAD_STOP_RECEIPTS_DIRNAME = 'orcad-stop-receipts' -export const ORCAD_COMPLETE_MANAGED_STOP_FLAG = '--complete-managed-stop' - -/** One orcad process: its PID, start time, and the instance lock record it published. */ -export const OrcadManagedStopInstanceSchema = z.strictObject({ - pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - startedAtMs: z.number().finite().nonnegative().nullable(), - nonce: z.string().min(1).max(255), - lockPath: z.string().min(1).max(4096) -}) - -export const OrcadManagedStopRequestSchema = z.strictObject({ - schemaVersion: z.literal(1), - transactionId: z.uuid(), - version: z.string().min(1).max(255), - runtimeId: z.string().min(1).max(255), - instance: OrcadManagedStopInstanceSchema, - /** Best effort: also retire the terminal daemon if it is provably idle. Never blocks the stop. */ - retireIdleDaemon: z.literal(true).optional() -}) - -export type OrcadManagedStopInstance = z.infer -export type OrcadManagedStopRequest = z.infer -/** What a stopping orcad must match before a managed request may stop it. */ -export type OrcadManagedStopContext = Omit< - OrcadManagedStopRequest, - 'schemaVersion' | 'transactionId' -> - -/** The execution host's verdict; `exited` only on proof, never on silence. */ -export const OrcadManagedStopVerdictSchema = z.enum(['live', 'unverifiable', 'exited']) - -// Outputs are read by clients that may be older than the host: unknown fields are tolerated. -export const OrcadManagedStopCompletionSchema = z.object({ - ...OrcadManagedStopRequestSchema.shape, - kind: z.literal('orcad_managed_stop_completion'), - verdict: OrcadManagedStopVerdictSchema, - receiptPersisted: z.boolean(), - /** For a request that asked to retire the daemon, the outcome its receipt recorded. */ - retirement: z.enum(['retired', 'live', 'unverifiable']).optional() -}) - -/** `retired` only when the daemon accepted; a busy daemon stays up and keeps its terminals. */ -export const OrcadDaemonRetirementVerdictSchema = z.enum(['retired', 'live', 'unverifiable']) - -/** What the stopping orcad observed about its daemon, written before it exits. */ -export const OrcadDaemonRetirementRecordSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_retirement'), - request: OrcadManagedStopRequestSchema, - retirement: OrcadDaemonRetirementVerdictSchema, - liveSessions: z.number().int().nonnegative().nullable(), - reason: z.string().max(4096).nullable() -}) - -export const OrcadCompletedStopReceiptSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_completed'), - request: OrcadManagedStopRequestSchema, - exitedAt: z.iso.datetime({ offset: true }), - /** Present only when the request asked to retire the daemon. */ - retirement: OrcadDaemonRetirementVerdictSchema.optional() -}) - -export type OrcadManagedStopVerdict = z.infer -export type OrcadManagedStopCompletion = z.infer -export type OrcadCompletedStopReceipt = z.infer -export type OrcadDaemonRetirementVerdict = z.infer -export type OrcadDaemonRetirementRecord = z.infer - -export const ORCAD_CANCEL_MANAGED_STOP_FLAG = '--cancel-managed-stop' -/** Readiness `health.stopRequests`: this build consumes stop files and both commands above. */ -export const ORCAD_STOP_REQUESTS_CAPABILITY = 1 - -/** - * Which side won a managed request: the running orcad acting on it, or a client cancelling it. - * Created exclusively once per transaction, so the two can never both believe they won. - */ -export const OrcadManagedStopDecisionSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_decision'), - request: OrcadManagedStopRequestSchema, - decision: z.enum(['dispatched', 'canceled']) -}) - -/** `dispatched` means orcad already acted on the request; the caller must await its exit. */ -export const OrcadManagedStopCancellationSchema = z.object({ - ...OrcadManagedStopRequestSchema.shape, - kind: z.literal('orcad_managed_stop_cancellation'), - outcome: z.enum(['canceled', 'dispatched']) -}) - -export type OrcadManagedStopDecision = z.infer -export type OrcadManagedStopCancellation = z.infer diff --git a/src/shared/orcad-terminal-census.ts b/src/shared/orcad-terminal-census.ts deleted file mode 100644 index 11cdfb3449d..00000000000 --- a/src/shared/orcad-terminal-census.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * How many terminals a host's daemon runs, as a client hands it to deploy, rollback and - * decommission planning. `null` means the daemon could not answer, which is never zero: - * loss of contact is not evidence of process death (docs/reference/ssh-execution-boundary.md). - */ -import { z } from 'zod' - -const CountSchema = z.number().int().nonnegative().nullable() - -export const OrcadTerminalCensusSchema = z.object({ - /** Sessions the live daemon owns right now. */ - liveSessions: CountSchema, - /** Of those, how many started at or after the active version's activation. */ - startedSinceActivation: CountSchema, - /** Protocol of the daemon that owns those sessions. */ - daemonProtocolVersion: z.number().int().positive().nullable() -}) - -export type OrcadTerminalCensus = z.infer - -/** RPC a managed orcad answers with its census; clients call it only when advertised. */ -export const ORCAD_TERMINAL_CENSUS_METHOD = 'orcad.terminalCensus' - -export const OrcadTerminalCensusParamsSchema = z.object({ - /** The active version's activation time, epoch ms; sessions created at or after it count. */ - activatedAt: z.number().finite().nonnegative() -}) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index b44e78b82fd..5d3217f00f3 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -338,12 +338,7 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = 'agentSession.create.tab-id.v1' as const -// Why: older hosts answer orcad.terminalCensus with method-not-found, so a client asks only when -// this is advertised and otherwise treats the census as unverifiable, never as zero. -export const ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY = 'orcad.terminal-census.v1' as const - export const RUNTIME_CAPABILITIES = [ - ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, diff --git a/src/shared/pty-consumer-session-contract.ts b/src/shared/pty-consumer-session-contract.ts index 701943afcaf..49527f83959 100644 --- a/src/shared/pty-consumer-session-contract.ts +++ b/src/shared/pty-consumer-session-contract.ts @@ -1,5 +1,4 @@ export const PTY_CONSUMER_SESSION_PROTOCOL_VERSION = 1 -export const PTY_CONSUMER_RESUME_CLIENT_METHOD = 'pty.resumeClient' export const PTY_CONSUMER_OWNER_GRACE_MS = 30_000 export const PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR = -32041 // Why: recovery is blocked only while the incumbent owner's grant publication is still settling — a diff --git a/src/shared/pty-consumer-session-resume-only.test.ts b/src/shared/pty-consumer-session-resume-only.test.ts deleted file mode 100644 index 91f43885efb..00000000000 --- a/src/shared/pty-consumer-session-resume-only.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, - PtyConsumerSession, - type PtyConsumerAuthentication, - type PtyConsumerSessionHello -} from './pty-consumer-session' - -function authentication(overrides: Partial = {}) { - return { - connectionId: 'successor', - principal: 'desktop', - authenticated: true, - allowSessionOwner: true, - ...overrides - } -} - -function hello(overrides: Partial = {}): PtyConsumerSessionHello { - return { - clientInstanceId: 'client-a', - requestedRole: 'session-owner', - resume: { ownerGeneration: 1, ownerLease: 'lease-1' }, - ...overrides - } -} - -function fixture() { - let now = 0 - let lease = 0 - const createLease = vi.fn(() => `lease-${++lease}`) - const session = new PtyConsumerSession({ - serverBuildId: 'relay-build', - createLease, - ownerGraceMs: 30_000, - now: () => now - }) - return { session, createLease, advance: () => (now += 30_001) } -} - -function activate(session: PtyConsumerSession) { - const admission = session.admit( - hello({ resume: undefined }), - authentication({ connectionId: 'source' }) - ) - admission.commitPublication() - return admission -} - -describe('PtyConsumerSession resume-only admission', () => { - it('does not shorten disconnected-owner grace after a refused strict claim', () => { - let now = 0 - const session = new PtyConsumerSession({ - serverBuildId: 'relay-build', - ownerGraceMs: 30_000, - createLease: () => 'lease-1', - now: () => now - }) - activate(session) - session.close('source', 'peer-closed') - expect(() => - session.admitResumed( - hello({ resume: { ownerGeneration: 1, ownerLease: 'wrong' } }), - authentication() - ) - ).toThrow() - now = 1_000 - expect(session.admitResumed(hello(), authentication()).grant.resumed).toBe(true) - }) - it('refuses a missing owner without minting a lease or consuming generations or the connection', () => { - const { session, createLease } = fixture() - expect(() => session.admitResumed(hello(), authentication())).toThrow( - 'pty_consumer_resume_owner_missing' - ) - expect(createLease).not.toHaveBeenCalled() - expect(session.activeGrant('successor')).toBeNull() - const ordinary = session.admit(hello(), authentication()) - expect(ordinary.grant).toMatchObject({ - resumed: false, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'lease-1' - }) - expect(createLease).toHaveBeenCalledTimes(1) - }) - - it('publishes a valid resumed owner atomically without minting a new lease', () => { - const { session, createLease } = fixture() - const source = activate(session) - const successor = session.admitResumed(hello(), authentication()) - expect(successor.grant).toMatchObject({ - resumed: true, - ownerGeneration: 2, - clientGeneration: 2, - ownerLease: 'lease-1' - }) - expect(successor.displacedOwner).toEqual({ connectionId: 'source', grant: source.grant }) - expect(session.activeGrant('source')).toBe(source.grant) - expect(session.activeGrant('successor')).toBeNull() - successor.commitPublication() - expect(session.activeGrant('source')).toBeNull() - expect(session.activeGrant('successor')).toBe(successor.grant) - expect(createLease).toHaveBeenCalledTimes(1) - }) - - it('restores the incumbent on rollback and permits retry on the same connection', () => { - const { session } = fixture() - const source = activate(session) - const successor = session.admitResumed(hello(), authentication()) - successor.rollbackPublication() - successor.commitPublication() - expect(session.activeGrant('source')).toBe(source.grant) - expect(session.activeGrant('successor')).toBeNull() - const retry = session.admitResumed(hello(), authentication()) - expect(retry.grant).toMatchObject({ resumed: true, ownerGeneration: 3, ownerLease: 'lease-1' }) - retry.commitPublication() - expect(session.activeGrant('successor')).toBe(retry.grant) - }) - - it.each([ - ['lease', hello({ resume: { ownerGeneration: 1, ownerLease: 'wrong' } }), authentication()], - ['client', hello({ clientInstanceId: 'other' }), authentication()], - ['principal', hello(), authentication({ principal: 'other' })], - [ - 'generation', - hello({ resume: { ownerGeneration: 99, ownerLease: 'lease-1' } }), - authentication() - ] - ])( - 'refuses a wrong %s without disturbing the owner or consuming the connection', - (_name, request, auth) => { - const { session, createLease } = fixture() - const source = activate(session) - expect(() => session.admitResumed(request, auth)).toThrow() - expect(session.activeGrant('source')).toBe(source.grant) - const valid = session.admitResumed(hello(), authentication()) - expect(valid.grant).toMatchObject({ resumed: true, clientGeneration: 2, ownerGeneration: 2 }) - expect(createLease).toHaveBeenCalledTimes(1) - } - ) - - it('refuses expired ownership while leaving ordinary fresh-claim fallback intact', () => { - const { session, createLease, advance } = fixture() - activate(session) - session.close('source') - advance() - expect(() => session.admitResumed(hello(), authentication())).toThrow( - 'pty_consumer_resume_owner_missing' - ) - expect(createLease).toHaveBeenCalledTimes(1) - expect(session.admit(hello(), authentication()).grant).toMatchObject({ - resumed: false, - clientGeneration: 2, - ownerGeneration: 2, - ownerLease: 'lease-2' - }) - }) - - it.each([ - [ - 'missing proof', - hello({ resume: undefined }), - authentication(), - 'pty_consumer_resume_required' - ], - [ - 'subscriber', - hello({ requestedRole: 'subscriber' }), - authentication(), - 'pty_consumer_resume_required' - ], - [ - 'owner-ineligible', - hello(), - authentication({ allowSessionOwner: false }), - 'pty_consumer_resume_required' - ], - [ - 'unauthenticated', - hello(), - authentication({ authenticated: false }), - 'authentication required' - ] - ])('refuses %s without reserving the connection', (_name, request, auth, error) => { - const { session } = fixture() - activate(session) - expect(() => session.admitResumed(request, auth)).toThrow(error) - expect(session.admitResumed(hello(), authentication()).grant).toMatchObject({ - resumed: true, - clientGeneration: 2, - ownerGeneration: 2 - }) - }) - - it('preserves pending-recovery refusal and permits retry after rollback', () => { - const { session } = fixture() - const source = activate(session) - const pending = session.admitResumed(hello(), authentication({ connectionId: 'pending' })) - expect(() => session.admitResumed(hello(), authentication())).toThrow( - expect.objectContaining({ code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) - ) - expect(session.activeGrant('source')).toBe(source.grant) - pending.rollbackPublication() - const retry = session.admitResumed(hello(), authentication()) - expect(retry.grant).toMatchObject({ resumed: true, clientGeneration: 3, ownerGeneration: 3 }) - retry.commitPublication() - expect(session.activeGrant('source')).toBeNull() - }) - - it('recovers a disconnected owner within grace', () => { - const { session } = fixture() - activate(session) - session.close('source') - const successor = session.admitResumed(hello(), authentication()) - expect(successor.displacedOwner).toBeUndefined() - expect(successor.grant).toMatchObject({ - resumed: true, - ownerGeneration: 2, - ownerLease: 'lease-1' - }) - successor.commitPublication() - expect(session.activeGrant('successor')).toBe(successor.grant) - }) - - it('recovers the last durable generation only after the unpersisted successor disconnects', () => { - const { session, createLease } = fixture() - activate(session) - const first = session.admitResumed(hello(), authentication()) - first.commitPublication() - const retryAuthentication = authentication({ connectionId: 'retry' }) - expect(() => session.admitResumed(hello(), retryAuthentication)).toThrow('superseded') - session.close('successor', 'peer-closed') - const retry = session.admitResumed(hello(), retryAuthentication) - expect(retry.grant).toMatchObject({ resumed: true, ownerGeneration: 3, ownerLease: 'lease-1' }) - retry.commitPublication() - expect(createLease).toHaveBeenCalledOnce() - expect(session.activeGrant('retry')).toBe(retry.grant) - }) -}) diff --git a/src/shared/pty-consumer-session.ts b/src/shared/pty-consumer-session.ts index 4972b03d1eb..7c5b29aacfd 100644 --- a/src/shared/pty-consumer-session.ts +++ b/src/shared/pty-consumer-session.ts @@ -64,22 +64,6 @@ export class PtyConsumerSession { admit( hello: PtyConsumerSessionHello, authentication: PtyConsumerAuthentication - ): PtyConsumerSessionAdmission { - return this.admitInternal(hello, authentication, false) - } - - /** Migration recovery must not turn an absent historical owner into a fresh claim. */ - admitResumed( - hello: PtyConsumerSessionHello, - authentication: PtyConsumerAuthentication - ): PtyConsumerSessionAdmission { - return this.admitInternal(hello, authentication, true) - } - - private admitInternal( - hello: PtyConsumerSessionHello, - authentication: PtyConsumerAuthentication, - requireResume: boolean ): PtyConsumerSessionAdmission { validateHello(hello) assertNonEmptyString(authentication.connectionId, 'connectionId') @@ -87,14 +71,6 @@ export class PtyConsumerSession { if (!authentication.authenticated) { throw new Error('PTY consumer authentication required') } - if ( - requireResume && - (!hello.resume || - hello.requestedRole !== 'session-owner' || - !authentication.allowSessionOwner) - ) { - throw new Error('pty_consumer_resume_required') - } this.expireOwner() // Why even an identical repeat is rejected: the two responses settle their publications @@ -104,13 +80,6 @@ export class PtyConsumerSession { throw new Error('pty.openClient may be used only once per transport connection') } - if (requireResume) { - if (!this.owner) { - throw new Error('pty_consumer_resume_owner_missing') - } - // Refused recovery must not shorten an incumbent's disconnected-owner grace. - assertPtyConsumerOwnerRecovery(hello, authentication, this.owner) - } const owner = this.selectOwner(hello, authentication) const grant = Object.freeze({ protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, diff --git a/src/shared/pty-ownership-transfer-release-gate.test.ts b/src/shared/pty-ownership-transfer-release-gate.test.ts deleted file mode 100644 index dbfc79ed6ae..00000000000 --- a/src/shared/pty-ownership-transfer-release-gate.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isPtyOwnershipTransferMutationEnabled, - PTY_OWNERSHIP_TRANSFER_CANARY_ENV -} from './pty-ownership-transfer-release-gate' - -describe('PTY ownership-transfer release gate', () => { - it('is closed unless the exact canary value is present', () => { - expect(isPtyOwnershipTransferMutationEnabled({})).toBe(false) - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: 'true' - }) - ).toBe(false) - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: '1 ' - }) - ).toBe(false) - }) - - it('opens only for an explicit canary opt-in', () => { - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: '1' - }) - ).toBe(true) - }) -}) diff --git a/src/shared/pty-ownership-transfer-release-gate.ts b/src/shared/pty-ownership-transfer-release-gate.ts deleted file mode 100644 index 2c411d89707..00000000000 --- a/src/shared/pty-ownership-transfer-release-gate.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * The live PTY ownership-transfer path is a release canary until routed, - * cross-platform recovery evidence is complete. Keep the opt-in exact and - * process-scoped so ordinary profiles cannot inherit it accidentally. - */ -export const PTY_OWNERSHIP_TRANSFER_CANARY_ENV = 'ORCA_ENABLE_PTY_OWNERSHIP_TRANSFER_MUTATION' - -export function isPtyOwnershipTransferMutationEnabled( - env: Record = process.env -): boolean { - return env[PTY_OWNERSHIP_TRANSFER_CANARY_ENV] === '1' -} diff --git a/src/shared/relay-owner-reset-contract.test.ts b/src/shared/relay-owner-reset-contract.test.ts deleted file mode 100644 index a52304195b6..00000000000 --- a/src/shared/relay-owner-reset-contract.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from './relay-owner-reset-contract' -import { RELAY_RESET_PREPARATION_READ_FLAG } from './relay-reset-preparation-contract' -import { allowsRelayWorkDuringDrain } from './relay-work-drain-contract' - -// Relays and clients update independently, so these names are permanent once released. -describe('relay owner-reset wire contract', () => { - it('pins the advertised capabilities, methods and reader flag', () => { - expect(RELAY_OWNER_RESET_CAPABILITY).toBe('relay.ownerReset.v1') - expect(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY).toBe('relay.durableResetPreparation.v1') - expect(RELAY_OWNER_RESET_METHOD).toBe('relay.reset') - expect(RELAY_PREPARED_RESET_RECOVERY_METHOD).toBe('relay.recoverPreparedReset') - expect(RELAY_RESET_PREPARATION_READ_FLAG).toBe('--read-reset-preparation') - }) - - it('admits both reset requests during a work drain, so a prepared reset can settle', () => { - expect(allowsRelayWorkDuringDrain(RELAY_OWNER_RESET_METHOD)).toBe(true) - expect(allowsRelayWorkDuringDrain(RELAY_PREPARED_RESET_RECOVERY_METHOD)).toBe(true) - }) -}) diff --git a/src/shared/relay-owner-reset-contract.ts b/src/shared/relay-owner-reset-contract.ts deleted file mode 100644 index 28ceb9a546a..00000000000 --- a/src/shared/relay-owner-reset-contract.ts +++ /dev/null @@ -1,94 +0,0 @@ -export const RELAY_OWNER_RESET_CAPABILITY = 'relay.ownerReset.v1' -export const RELAY_DURABLE_RESET_PREPARATION_CAPABILITY = 'relay.durableResetPreparation.v1' -export const RELAY_OWNER_RESET_METHOD = 'relay.reset' -export const RELAY_PREPARED_RESET_RECOVERY_METHOD = 'relay.recoverPreparedReset' - -export type RelayOwnerResetRequest = Readonly<{ - version: 1 - operationId: string - runtimeIncarnation: string - ownerGeneration: number - ownerLease: string -}> - -export type RelayOwnerResetAcknowledgment = { - version: 1 - operationId: string - runtimeIncarnation: string - prepared: true -} - -/** A wire value as a plain record; anything else reads as absent. */ -export function readRelayResetRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) ? { ...value } : null -} - -function isBoundedText(field: unknown, max: number): field is string { - return typeof field === 'string' && field.length > 0 && field.length <= max -} - -export function parseRelayOwnerResetRequest(value: unknown): RelayOwnerResetRequest { - const record = readRelayResetRecord(value) - const operationId = record?.operationId - const runtimeIncarnation = record?.runtimeIncarnation - const ownerGeneration = record?.ownerGeneration - const ownerLease = record?.ownerLease - if ( - record?.version !== 1 || - !isBoundedText(operationId, 128) || - !isBoundedText(runtimeIncarnation, 128) || - typeof ownerGeneration !== 'number' || - !Number.isSafeInteger(ownerGeneration) || - ownerGeneration < 1 || - !isBoundedText(ownerLease, 1024) - ) { - throw new Error('relay_reset_invalid_request') - } - return Object.freeze({ - version: 1, - operationId, - runtimeIncarnation, - ownerGeneration, - ownerLease - }) -} - -export function readRelayOwnerResetIncarnation(status: unknown): string { - const value = readRelayResetRecord(status) - const capabilities = value?.capabilities - const ownerReset = readRelayResetRecord(value?.ownerReset) - const incarnation = ownerReset?.runtimeIncarnation - if ( - !Array.isArray(capabilities) || - !capabilities.includes(RELAY_OWNER_RESET_CAPABILITY) || - ownerReset?.version !== 1 || - typeof incarnation !== 'string' || - incarnation.length === 0 || - incarnation.length > 128 - ) { - throw new Error('relay_reset_capability_unavailable') - } - return incarnation -} - -export function parseRelayOwnerResetAcknowledgment( - value: unknown, - expected: RelayOwnerResetRequest -): RelayOwnerResetAcknowledgment { - const result = readRelayResetRecord(value) - if ( - !result || - result.version !== 1 || - result.prepared !== true || - result.operationId !== expected.operationId || - result.runtimeIncarnation !== expected.runtimeIncarnation - ) { - throw new Error('relay_reset_acknowledgment_invalid') - } - return { - version: 1, - operationId: expected.operationId, - runtimeIncarnation: expected.runtimeIncarnation, - prepared: true - } -} diff --git a/src/shared/relay-reset-preparation-contract.test.ts b/src/shared/relay-reset-preparation-contract.test.ts deleted file mode 100644 index 968a116031b..00000000000 --- a/src/shared/relay-reset-preparation-contract.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { expect, it } from 'vitest' -import { RELAY_DURABLE_RESET_PREPARATION_CAPABILITY } from './relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - readRelayResetPreparationBinding, - validateRelayResetPreparationRecord -} from './relay-reset-preparation-contract' - -const binding = parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: '/journal', - principal: 'owner', - authenticationKind: 'endpoint-credential', - sockPath: '/socket', - serverBuildId: 'build' -}) -const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'lease' -} -const record = { - version: 1, - prepared: true, - request, - principal: binding.principal, - authenticationKind: binding.authenticationKind, - sockPath: binding.sockPath, - serverBuildId: binding.serverBuildId -} - -it('does not infer durable recovery support from unnegotiated metadata', () => { - expect(readRelayResetPreparationBinding({ ownerReset: { preparation: binding } })).toBeUndefined() - expect(readRelayResetPreparationBinding({ capabilities: [] })).toBeUndefined() -}) - -it('requires valid metadata once durable preparation is advertised', () => { - const capabilities = [RELAY_DURABLE_RESET_PREPARATION_CAPABILITY] - expect(() => readRelayResetPreparationBinding({ capabilities })).toThrow('invalid') - expect( - readRelayResetPreparationBinding({ capabilities, ownerReset: { preparation: binding } }) - ).toEqual(binding) -}) - -it('validates exact preparation without adding an exit or cleanup verdict', () => { - const result = validateRelayResetPreparationRecord(record, binding, request) - expect(result).toEqual(record) - expect(Object.isFrozen(result)).toBe(true) - expect(Object.isFrozen(result.request)).toBe(true) -}) - -it('preserves optional reader negotiation without assuming old hosts implement it', () => { - expect(parseRelayResetPreparationBinding(binding).readerVersion).toBeUndefined() - expect(parseRelayResetPreparationBinding({ ...binding, readerVersion: 1 }).readerVersion).toBe(1) - expect(() => parseRelayResetPreparationBinding({ ...binding, readerVersion: 2 })).toThrow( - 'reader_version_invalid' - ) -}) - -it.each(['principal', 'authenticationKind', 'sockPath', 'serverBuildId'] as const)( - 'rejects changed %s evidence', - (field) => { - const value = field === 'authenticationKind' ? 'launch-nonce' : 'other' - expect(() => - validateRelayResetPreparationRecord({ ...record, [field]: value }, binding, request) - ).toThrow('conflict') - } -) - -it.each(['operationId', 'runtimeIncarnation', 'ownerGeneration', 'ownerLease'] as const)( - 'rejects a changed request %s', - (field) => { - const value = field === 'ownerGeneration' ? 2 : 'other' - expect(() => - validateRelayResetPreparationRecord( - { ...record, request: { ...request, [field]: value } }, - binding, - request - ) - ).toThrow('conflict') - } -) - -it.each(['', 'x\0y', 'x'.repeat(8193)])('refuses invalid journal directory', (journalDirectory) => { - expect(() => parseRelayResetPreparationBinding({ ...binding, journalDirectory })).toThrow( - 'invalid' - ) -}) diff --git a/src/shared/relay-reset-preparation-contract.ts b/src/shared/relay-reset-preparation-contract.ts deleted file mode 100644 index 44bc7d5300a..00000000000 --- a/src/shared/relay-reset-preparation-contract.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - parseRelayOwnerResetRequest, - readRelayResetRecord, - type RelayOwnerResetRequest -} from './relay-owner-reset-contract' - -export const RELAY_RESET_PREPARATION_READ_FLAG = '--read-reset-preparation' - -export type RelayResetPreparationBinding = Readonly<{ - version: 1 - journalDirectory: string - readerVersion?: 1 - principal: string - authenticationKind: 'launch-nonce' | 'endpoint-credential' - sockPath: string - serverBuildId: string -}> -export type RelayResetPreparationRecord = Readonly< - Omit & { - prepared: true - request: RelayOwnerResetRequest - } -> - -function isJournalText(value: unknown): value is string { - return ( - typeof value === 'string' && value.length > 0 && value.length <= 8192 && !value.includes('\0') - ) -} - -function isAuthenticationKind( - value: unknown -): value is RelayResetPreparationBinding['authenticationKind'] { - return value === 'launch-nonce' || value === 'endpoint-credential' -} - -function parseIdentity(record: Record | null) { - const { principal, sockPath, serverBuildId, authenticationKind } = record ?? {} - if ( - record?.version !== 1 || - !isJournalText(principal) || - !isJournalText(sockPath) || - !isJournalText(serverBuildId) || - !isAuthenticationKind(authenticationKind) - ) { - throw new Error('relay_reset_preparation_journal_invalid') - } - return { version: 1 as const, principal, authenticationKind, sockPath, serverBuildId } -} - -export function parseRelayResetPreparationBinding(value: unknown): RelayResetPreparationBinding { - const record = readRelayResetRecord(value) - const identity = parseIdentity(record) - const directory = record?.journalDirectory - const readerVersion = record?.readerVersion - if (!isJournalText(directory)) { - throw new Error('relay_reset_preparation_journal_invalid') - } - if (readerVersion !== undefined && readerVersion !== 1) { - throw new Error('relay_reset_preparation_reader_version_invalid') - } - return Object.freeze({ - ...identity, - journalDirectory: directory, - ...(readerVersion === undefined ? {} : { readerVersion }) - }) -} - -export function parseRelayResetPreparationRecord(value: unknown): RelayResetPreparationRecord { - const record = readRelayResetRecord(value) - const identity = parseIdentity(record) - if (record?.prepared !== true) { - throw new Error('relay_reset_preparation_journal_invalid') - } - return Object.freeze({ - version: 1, - prepared: true, - request: parseRelayOwnerResetRequest(record.request), - principal: identity.principal, - authenticationKind: identity.authenticationKind, - sockPath: identity.sockPath, - serverBuildId: identity.serverBuildId - }) -} - -export function readRelayResetPreparationBinding( - status: unknown -): RelayResetPreparationBinding | undefined { - const value = readRelayResetRecord(status) - const capabilities = value?.capabilities - if ( - !Array.isArray(capabilities) || - !capabilities.includes(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - ) { - return undefined - } - return parseRelayResetPreparationBinding(readRelayResetRecord(value?.ownerReset)?.preparation) -} - -export function validateRelayResetPreparationRecord( - value: unknown, - expectedBinding: RelayResetPreparationBinding, - expectedRequest: RelayOwnerResetRequest -): RelayResetPreparationRecord { - const record = parseRelayResetPreparationRecord(value) - const binding = parseRelayResetPreparationBinding(expectedBinding) - if ( - record.principal !== binding.principal || - record.authenticationKind !== binding.authenticationKind || - record.sockPath !== binding.sockPath || - record.serverBuildId !== binding.serverBuildId || - JSON.stringify(record.request) !== JSON.stringify(parseRelayOwnerResetRequest(expectedRequest)) - ) { - throw new Error('relay_reset_preparation_journal_conflict') - } - return record -} diff --git a/src/shared/relay-work-drain-contract.ts b/src/shared/relay-work-drain-contract.ts deleted file mode 100644 index c2902597ec5..00000000000 --- a/src/shared/relay-work-drain-contract.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD } from './skill-ssh-relay-contract' -import { - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from './relay-owner-reset-contract' - -// Why: a drain must still admit the requests and notifications that let in-flight work finish or cancel. -// Network-tunnel (T4) methods join these sets when their contract lands. -const drainRequests = new Set([ - 'relay.status', - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD, - 'fs.unwatchAndWait', - 'agent.cancelExec', - // Why (not in #16741): it only retires an existing delivery and replays its proof on retry; - // refusing it turns a provable client cancellation into an unverifiable one. - 'pty.cancelDelivery', - SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD -]) -const drainNotifications = new Set([ - 'rpc.cancel', - 'git.responseAck', - 'git.cancelResponseStream', - 'fs.streamAck', - 'fs.cancelStream', - 'fs.unwatch', - 'pty.ackData', - 'pty.setDeliveryPaused' -]) - -export function allowsRelayWorkDuringDrain(method: string, notification = false): boolean { - return (notification ? drainNotifications : drainRequests).has(method) -} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index ca4f857e0fa..9b6680b08c2 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -23,7 +23,6 @@ import { PairingProvisionRelayParamsSchema } from '../mobile-relay-credential-contract' import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract' -import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census' import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request' import { AccountsUnsubscribeParams, @@ -988,7 +987,6 @@ export const RPC_PARAMS_BY_METHOD = { 'notifications.testPush': null, 'notifications.unregisterPush': null, 'notifications.unsubscribe': NotificationUnsubscribeParams, - 'orcad.terminalCensus': OrcadTerminalCensusParamsSchema, 'orchestration.ask': AskParams, 'orchestration.callerShow': null, 'orchestration.check': CheckParams, diff --git a/src/shared/runtime-bootstrap.ts b/src/shared/runtime-bootstrap.ts index ca8cd456b72..993ab5e023d 100644 --- a/src/shared/runtime-bootstrap.ts +++ b/src/shared/runtime-bootstrap.ts @@ -42,7 +42,7 @@ export function findTransport( return null } -export const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json' +const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json' export function getRuntimeMetadataPath(userDataPath: string): string { return join(userDataPath, PRIMARY_RUNTIME_METADATA_FILE) diff --git a/src/shared/runtime-environment-authority-binding.ts b/src/shared/runtime-environment-authority-binding.ts deleted file mode 100644 index 79b6e114957..00000000000 --- a/src/shared/runtime-environment-authority-binding.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { KnownRuntimeEnvironment, PublicKnownRuntimeEnvironment } from './runtime-environments' - -/** Everything an SSH access operation was authorized against; any change invalidates the operation. */ -export function runtimeEnvironmentSshAccessBinding( - environment: KnownRuntimeEnvironment | PublicKnownRuntimeEnvironment, - ignoreIntent = false -): unknown { - return { - createdAt: environment.createdAt, - pairingRevision: environment.pairingRevision ?? environment.createdAt, - runtimeId: environment.runtimeId, - pairedDeviceId: environment.pairedDeviceId, - preferredEndpointId: environment.preferredEndpointId, - endpoints: environment.endpoints, - connectionDependency: environment.connectionDependency, - sshAccess: environment.sshAccess, - pendingSshAccessOperation: ignoreIntent ? undefined : environment.pendingSshAccessOperation - } -} diff --git a/src/shared/runtime-environment-managed-orcad-store.test.ts b/src/shared/runtime-environment-managed-orcad-store.test.ts deleted file mode 100644 index 2458ad176fa..00000000000 --- a/src/shared/runtime-environment-managed-orcad-store.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { encodePairingOffer } from './pairing' -import { - getEnvironmentStorePath, - listEnvironments, - removeEnvironment, - updateEnvironmentFromPairingCode -} from './runtime-environment-store' -import { - addManagedOrcadEnvironment, - refreshManagedOrcadPairing, - removeManagedOrcadEnvironment -} from './runtime-environment-managed-orcad-store' -import { getRuntimeEnvironmentSidecarPath } from './runtime-environment-sidecar' -import { getRuntimeSshAccess } from './runtime-environments' -import { shippedBuildRewrite } from './runtime-environment-shipped-store-fixture' - -const deployment = { - sshTargetId: 'ssh-1', - sshTargetGeneration: 4, - localPort: 46_768, - remotePort: 6_768 -} -const pairingCode = (endpoint = 'ws://127.0.0.1:46768') => - encodePairingOffer({ - v: 2, - endpoint, - deviceToken: 'private-device-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64') - }) - -describe('managed orcad environment store', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-managed-orcad-store-')) - }) - afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - - const add = (overrides: Partial[1]> = {}) => - addManagedOrcadEnvironment(userDataPath, { - id: 'environment-1', - name: 'Managed', - pairingCode: pairingCode(), - orcadDeployment: deployment, - now: 100, - ...overrides - }) - - it('keeps the deployment link out of the file shipped builds rewrite', () => { - const environment = add() - expect(environment.orcadDeployment).toEqual(deployment) - expect(getRuntimeSshAccess(environment)).toEqual(deployment) - const persisted = readFileSync(getEnvironmentStorePath(userDataPath), 'utf8') - expect(persisted).not.toContain('orcadDeployment') - expect(persisted).toContain('"connectionDependency":"ssh-tunnel"') - expect(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')).not.toContain( - 'private-device-token' - ) - }) - - it('survives a downgraded build rewriting orca-environments.json', () => { - add() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.lastUsedAt = 500 - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.orcadDeployment).toEqual(deployment) - expect(restored?.lastUsedAt).toBe(500) - }) - - it('drops the link when a downgraded build re-pairs the server elsewhere', () => { - add() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.pairingRevision = 200 - }) - expect(listEnvironments(userDataPath)[0]?.orcadDeployment).toBeUndefined() - }) - - it('refuses a pairing that does not point at the deployment tunnel', () => { - expect(() => add({ pairingCode: pairingCode('ws://127.0.0.1:1234') })).toThrow( - 'does not point at its SSH tunnel' - ) - expect(() => add({ pairingCode: pairingCode('wss://server.example') })).toThrow() - expect(listEnvironments(userDataPath)).toEqual([]) - }) - - it('refuses duplicate ids and names', () => { - add() - expect(() => add({ name: 'Other' })).toThrow('already exists') - expect(() => add({ id: 'environment-2' })).toThrow('already exists') - }) - - it('keeps a managed server from being removed or re-paired outside its lifecycle', () => { - add() - expect(() => removeEnvironment(userDataPath, 'Managed')).toThrow('managed by Orca over SSH') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, 'Managed', { pairingCode: pairingCode() }) - ).toThrow('managed by Orca over SSH') - expect(listEnvironments(userDataPath)).toHaveLength(1) - }) - - it('removes the server and its deployment record together once it is unlinked', () => { - add() - removeManagedOrcadEnvironment(userDataPath, 'environment-1') - expect(listEnvironments(userDataPath)).toEqual([]) - expect(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')).not.toContain( - 'environment-1' - ) - }) - - it('leaves an unchanged pairing alone and re-binds the link when the offer rotates', () => { - const environment = add() - expect(refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode())).toEqual( - environment - ) - const rotated = encodePairingOffer({ - v: 2, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'rotated-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64') - }) - const refreshed = refreshManagedOrcadPairing(userDataPath, 'environment-1', rotated, 500) - expect(refreshed.endpoints[0]?.deviceToken).toBe('rotated-token') - expect(refreshed.pairingRevision).toBe(500) - expect(refreshed.orcadDeployment).toEqual(deployment) - expect(() => - refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode('ws://127.0.0.1:1')) - ).toThrow('does not point at its SSH tunnel') - }) -}) diff --git a/src/shared/runtime-environment-managed-orcad-store.ts b/src/shared/runtime-environment-managed-orcad-store.ts deleted file mode 100644 index 7ca7e1dd9b5..00000000000 --- a/src/shared/runtime-environment-managed-orcad-store.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { parsePairingCode, type PairingOffer } from './pairing' -import { - createEnvironmentFromPairingOffer, - getPreferredLoopbackRuntimePort, - getPreferredPairingOffer, - PersistedRuntimeEnvironmentSchema, - type KnownRuntimeEnvironment, - type OrcadDeploymentLink -} from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError, - writeEnvironmentStore -} from './runtime-environment-store-file' -import { - readCurrentRuntimeEnvironmentSidecarEntry, - writeRuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' -import { resolveEnvironmentFromStore } from './runtime-environment-store' - -/** Registers a server Orca deployed over SSH, paired through its own loopback tunnel. */ -export function addManagedOrcadEnvironment( - userDataPath: string, - args: { - id: string - name: string - pairingCode: string - orcadDeployment: OrcadDeploymentLink - now?: number - } -): KnownRuntimeEnvironment { - const offer = parsePairingCode(args.pairingCode) - if (!offer) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'Invalid managed pairing code.') - } - const known = readEnvironmentStore(userDataPath).environments - if (known.some((entry) => entry.id === args.id)) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - `A server with id "${args.id}" already exists.` - ) - } - if (known.some((entry) => entry.name === args.name)) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - `A server named "${args.name}" already exists.` - ) - } - const environment = createEnvironmentFromPairingOffer({ - id: args.id, - name: args.name, - now: args.now ?? Date.now(), - offer, - runtimeId: null, - connectionDependency: 'ssh-tunnel' - }) - if (getPreferredLoopbackRuntimePort(environment) !== args.orcadDeployment.localPort) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'The managed pairing does not point at its SSH tunnel.' - ) - } - const persisted = PersistedRuntimeEnvironmentSchema.parse(environment) - const environments = [ - ...readPersistedEnvironmentStore(userDataPath).environments, - persisted - ].sort((a, b) => a.name.localeCompare(b.name)) - // Sidecar first: a crash between the writes leaves an ownerless entry the next write prunes, - // never a registered server that has lost its deployment link. - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, persisted, { - orcadDeployment: args.orcadDeployment - }) - writeEnvironmentStore(userDataPath, { version: 1, environments }) - const registered = readEnvironmentStore(userDataPath).environments.find( - (entry) => entry.id === args.id - ) - if (!registered?.orcadDeployment) { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - 'The managed server was saved without its deployment link.' - ) - } - return registered -} - -/** - * Unlinks a managed server once its orcad is proven to have exited: drops the persisted entry - * and the sidecar record that held its deployment link. - */ -export function removeManagedOrcadEnvironment(userDataPath: string, environmentId: string): void { - const store = readPersistedEnvironmentStore(userDataPath) - const persisted = resolveEnvironmentFromStore(store, environmentId) - const remaining = store.environments.filter((entry) => entry.id !== persisted.id) - writeEnvironmentStore(userDataPath, { version: 1, environments: remaining }) - writeRuntimeEnvironmentSidecarEntry(userDataPath, remaining, persisted, null) -} - -/** Keeps a managed server's pairing current after its orcad changed versions. */ -export function refreshManagedOrcadPairing( - userDataPath: string, - environmentId: string, - pairingCode: string, - now = Date.now() -): KnownRuntimeEnvironment { - const offer = parsePairingCode(pairingCode) - if (!offer) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'Invalid managed pairing code.') - } - const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId) - const deployment = current.orcadDeployment - if (!deployment) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'This server is not managed.') - } - // Why skip: orcad keeps its pairing state across versions, so an unchanged offer needs no - // rewrite, and every rewrite re-binds the sidecar entry. - if (samePairing(getPreferredPairingOffer(current), offer)) { - return current - } - const store = readPersistedEnvironmentStore(userDataPath) - const existing = resolveEnvironmentFromStore(store, environmentId) - const entry = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, existing) - const next = PersistedRuntimeEnvironmentSchema.parse({ - ...createEnvironmentFromPairingOffer({ - id: existing.id, - name: existing.name, - now: existing.createdAt, - offer, - runtimeId: existing.runtimeId, - connectionDependency: 'ssh-tunnel' - }), - updatedAt: now, - pairingRevision: Math.max(now, (existing.pairingRevision ?? existing.createdAt) + 1), - lastUsedAt: existing.lastUsedAt - }) - if (getPreferredLoopbackRuntimePort(next) !== deployment.localPort) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'The managed pairing does not point at its SSH tunnel.' - ) - } - const environments = store.environments.map((candidate) => - candidate.id === existing.id ? next : candidate - ) - const { binding: _binding, ...state } = entry ?? {} - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, next, { - ...state, - orcadDeployment: deployment - }) - writeEnvironmentStore(userDataPath, { version: 1, environments }) - return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId) -} - -function samePairing(left: PairingOffer, right: PairingOffer): boolean { - return ( - left.endpoint === right.endpoint && - left.deviceToken === right.deviceToken && - left.publicKeyB64 === right.publicKeyB64 && - left.pairedDeviceId === right.pairedDeviceId - ) -} diff --git a/src/shared/runtime-environment-reconciliation-record.ts b/src/shared/runtime-environment-reconciliation-record.ts deleted file mode 100644 index 232ef9bfb95..00000000000 --- a/src/shared/runtime-environment-reconciliation-record.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod' - -export const RuntimeEnvironmentReconciliationRecordSchema = z.object({ - version: z.literal(1), - stage: z.enum(['prepared', 'catalog-active']), - requestId: z.string().min(1), - canonicalEnvironmentId: z.string().min(1), - runtimeId: z.string().min(1), - preparedAt: z.number().finite(), - registrations: z - .array( - z.object({ - environmentId: z.string().min(1), - authorityDigest: z.string().regex(/^[a-f0-9]{64}$/) - }) - ) - .length(2) -}) - -export type RuntimeEnvironmentReconciliationRecord = z.infer< - typeof RuntimeEnvironmentReconciliationRecordSchema -> - -export function assertRuntimeEnvironmentNotReconciling(environment: { - reconciliation?: unknown -}): void { - if (environment.reconciliation) { - throw new Error('Finish or cancel host reconciliation before changing this server lifecycle.') - } -} diff --git a/src/shared/runtime-environment-shipped-store-fixture.ts b/src/shared/runtime-environment-shipped-store-fixture.ts deleted file mode 100644 index fa0be626461..00000000000 --- a/src/shared/runtime-environment-shipped-store-fixture.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs' -import { z } from 'zod' -import { getEnvironmentStorePath } from './runtime-environment-store' - -// The environment schema v1.4.217 and v1.4.218 shipped: a plain z.object, so unknown keys are -// stripped, and every write (lastUsedAt included) rewrites the whole file. -const ShippedEnvironmentSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - createdAt: z.number().finite(), - updatedAt: z.number().finite(), - pairingRevision: z.number().finite().optional(), - pairedDeviceId: z.string().min(1).optional(), - lastUsedAt: z.number().finite().nullable(), - runtimeId: z.string().min(1).nullable(), - source: z.enum(['manual', 'ephemeral-vm']).optional(), - connectionDependency: z.literal('ssh-tunnel').optional(), - endpoints: z.array(z.object({}).passthrough()).min(1), - preferredEndpointId: z.string().min(1) -}) -const ShippedStoreSchema = z.object({ - version: z.literal(1), - environments: z.array(ShippedEnvironmentSchema) -}) - -/** Rewrites orca-environments.json the way a downgraded build does, stripping unknown keys. */ -export function shippedBuildRewrite( - userDataPath: string, - edit: (environments: z.infer[]) => void = () => {} -): void { - const path = getEnvironmentStorePath(userDataPath) - const store = ShippedStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) - edit(store.environments) - writeFileSync(path, JSON.stringify(store)) -} diff --git a/src/shared/runtime-environment-sidecar.test.ts b/src/shared/runtime-environment-sidecar.test.ts deleted file mode 100644 index c65788695a8..00000000000 --- a/src/shared/runtime-environment-sidecar.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { encodePairingOffer } from './pairing' -import { - addEnvironmentFromPairingCode, - getEnvironmentStorePath, - listEnvironments, - removeEnvironment -} from './runtime-environment-store' -import { - getRuntimeEnvironmentSidecarPath, - writeRuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' -import { assertRuntimeEnvironmentNotReconciling } from './runtime-environment-reconciliation-record' -import { - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink -} from './runtime-environment-ssh-access-store' -import { readPersistedEnvironmentStore } from './runtime-environment-store-file' -import { shippedBuildRewrite } from './runtime-environment-shipped-store-fixture' - -const pairing = { - v: 2 as const, - endpoint: 'wss://server.example/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'private-device-token', - pairedDeviceId: 'paired-client' -} -const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 } - -describe('runtime environment sidecar across a downgrade', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-environment-sidecar-')) - }) - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function linked() { - const seeded = addEnvironmentFromPairingCode(userDataPath, { - name: 'Host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - const prepared = prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: seeded, - requestId: 'link-request', - ...tunnel, - targetFingerprint: 'target-fingerprint' - }) - return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing: { ...pairing, endpoint: 'ws://127.0.0.1:41000/runtime' }, - tunnel, - now: 90 - }) - } - - it('keeps SSH access after a shipped build rewrites orca-environments.json', () => { - const access = linked() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.lastUsedAt = 500 - environments[0]!.runtimeId = 'host-runtime' - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.sshAccess).toEqual(access.sshAccess) - expect(restored?.preferredEndpointId).toBe(access.preferredEndpointId) - expect(restored?.lastUsedAt).toBe(500) - }) - - it('reads a link as stale once a shipped build re-pairs the server, and prunes it on the next write', () => { - const access = linked() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.pairingRevision = 200 - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.sshAccess).toBeUndefined() - expect(restored?.preferredEndpointId).toBe(access.sshAccess?.previousPreferredEndpointId) - expect(restored?.connectionDependency).toBeUndefined() - - const persisted = readPersistedEnvironmentStore(userDataPath).environments - writeRuntimeEnvironmentSidecarEntry(userDataPath, persisted, persisted[0]!, null) - expect( - JSON.parse(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')) - ).toEqual({ version: 1, entries: {} }) - }) - - it('ignores a dangling entry for a server a shipped build removed', () => { - linked() - shippedBuildRewrite(userDataPath, (environments) => { - environments.splice(0, 1) - }) - expect(listEnvironments(userDataPath)).toEqual([]) - const other = addEnvironmentFromPairingCode(userDataPath, { - name: 'Other', - pairingCode: encodePairingOffer(pairing) - }) - expect(listEnvironments(userDataPath)).toEqual([other]) - }) - - it('refuses ordinary removal while linked, and drops the entry when an unlinked server is removed', () => { - const access = linked() - expect(() => removeEnvironment(userDataPath, access.id)).toThrow('Unlink') - const plain = addEnvironmentFromPairingCode(userDataPath, { - name: 'Plain', - pairingCode: encodePairingOffer(pairing) - }) - removeEnvironment(userDataPath, plain.id) - expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([access.id]) - }) - - it('fails closed on an unreadable sidecar instead of dropping a pinned host key', () => { - linked() - writeFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), '{not json') - expect(() => listEnvironments(userDataPath)).toThrow('Orca environment links') - }) - - it('overlays a reconciliation record so lifecycle changes refuse while it exists', () => { - const seeded = addEnvironmentFromPairingCode(userDataPath, { - name: 'Host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - const persisted = readPersistedEnvironmentStore(userDataPath).environments - writeRuntimeEnvironmentSidecarEntry(userDataPath, persisted, persisted[0]!, { - reconciliation: { - version: 1, - stage: 'prepared', - requestId: 'reconcile', - canonicalEnvironmentId: seeded.id, - runtimeId: 'host-runtime', - preparedAt: 1, - registrations: [ - { environmentId: seeded.id, authorityDigest: 'a'.repeat(64) }, - { environmentId: 'other', authorityDigest: 'b'.repeat(64) } - ] - } - }) - const [restored] = listEnvironments(userDataPath) - expect(() => assertRuntimeEnvironmentNotReconciling(restored!)).toThrow('reconciliation') - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8'))).toEqual({ - version: 1, - environments: [seeded] - }) - }) -}) diff --git a/src/shared/runtime-environment-sidecar.ts b/src/shared/runtime-environment-sidecar.ts deleted file mode 100644 index d421d0e8794..00000000000 --- a/src/shared/runtime-environment-sidecar.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { z } from 'zod' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' -import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record' -import { - KnownRuntimeEnvironmentSchema, - OrcadDeploymentLinkSchema, - RuntimeAccessEndpointSchema, - RuntimeSshAccessLinkSchema, - RuntimeSshAccessOperationSchema, - type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment -} from './runtime-environments' -import { hardenExistingSecureFile } from './secure-file' - -// Why a sidecar: shipped builds parse orca-environments.json with key-stripping schemas and rewrite -// it on routine use, so T5 state stored there would vanish across a downgrade. They never touch this. -const SIDECAR_FILE = 'orca-environment-sidecar.json' -const MAX_SIDECAR_FILE_BYTES = 1024 * 1024 - -const SidecarBindingSchema = z.object({ - createdAt: z.number().finite(), - pairingRevision: z.number().finite(), - preferredEndpointId: z.string().min(1) -}) - -const SidecarSshAccessSchema = RuntimeSshAccessLinkSchema.extend({ - endpoint: RuntimeAccessEndpointSchema -}) - -const SidecarEntrySchema = z.object({ - binding: SidecarBindingSchema, - // The runtime a link verified; it outlives the link like a learned runtimeId would. - runtimeId: z.string().min(1).optional(), - sshAccess: SidecarSshAccessSchema.optional(), - pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), - // The managed deployment's tunnel; its loopback endpoint is the persisted pairing itself. - orcadDeployment: OrcadDeploymentLinkSchema.optional(), - // Keeps the overlaid pairing revision monotonic after the access that raised it is removed. - pairingRevisionFloor: z.number().finite().optional(), - reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() -}) - -const SidecarSchema = z.object({ - version: z.literal(1), - entries: z.record(z.string().min(1), SidecarEntrySchema) -}) - -export type RuntimeEnvironmentSidecarEntry = z.infer -export type RuntimeEnvironmentSidecarSshAccess = z.infer -type RuntimeEnvironmentSidecar = z.infer - -export class RuntimeEnvironmentSidecarInvalidError extends Error { - constructor(path: string) { - super(`Could not read Orca environment links at ${path}; the file is invalid.`) - this.name = 'RuntimeEnvironmentSidecarInvalidError' - } -} - -export function getRuntimeEnvironmentSidecarPath(userDataPath: string): string { - return join(userDataPath, SIDECAR_FILE) -} - -/** The persisted state a sidecar entry was written against; any change makes the entry stale. */ -export function runtimeEnvironmentSidecarBinding(environment: PersistedRuntimeEnvironment) { - return { - createdAt: environment.createdAt, - pairingRevision: environment.pairingRevision ?? environment.createdAt, - preferredEndpointId: environment.preferredEndpointId - } -} - -export function readRuntimeEnvironmentSidecar(userDataPath: string): RuntimeEnvironmentSidecar { - const path = getRuntimeEnvironmentSidecarPath(userDataPath) - if (!existsSync(path)) { - return { version: 1, entries: {} } - } - try { - hardenExistingSecureFile(path) - return SidecarSchema.parse( - JSON.parse(readNodeFileSyncWithinLimit(path, MAX_SIDECAR_FILE_BYTES).buffer.toString('utf8')) - ) - } catch { - throw new RuntimeEnvironmentSidecarInvalidError(path) - } -} - -function isCurrentEntry( - environment: PersistedRuntimeEnvironment, - entry: RuntimeEnvironmentSidecarEntry -): boolean { - const binding = runtimeEnvironmentSidecarBinding(environment) - return ( - entry.binding.createdAt === binding.createdAt && - entry.binding.pairingRevision === binding.pairingRevision && - entry.binding.preferredEndpointId === binding.preferredEndpointId - ) -} - -/** - * The environment callers see. An entry bound to a different pairing, or one whose overlay would be - * inconsistent, is stale and ignored rather than trusted. - */ -export function overlayRuntimeEnvironmentSidecar( - environment: PersistedRuntimeEnvironment, - entry: RuntimeEnvironmentSidecarEntry | undefined -): KnownRuntimeEnvironment { - const base = KnownRuntimeEnvironmentSchema.parse(environment) - if (!entry || !isCurrentEntry(environment, entry)) { - return base - } - const basePairingRevision = environment.pairingRevision ?? environment.createdAt - const access = entry.sshAccess - if ( - entry.runtimeId !== undefined && - environment.runtimeId !== null && - environment.runtimeId !== entry.runtimeId - ) { - return base - } - const pairingRevision = Math.max( - basePairingRevision, - entry.pairingRevisionFloor ?? basePairingRevision - ) - const { endpoint, ...link } = access ?? {} - const overlaid = KnownRuntimeEnvironmentSchema.safeParse({ - ...environment, - ...(pairingRevision !== basePairingRevision ? { pairingRevision } : {}), - ...(entry.runtimeId !== undefined - ? { runtimeId: environment.runtimeId ?? entry.runtimeId } - : {}), - ...(access && endpoint - ? { - connectionDependency: 'ssh-tunnel', - sshAccess: link, - endpoints: [...environment.endpoints.filter((e) => e.id !== endpoint.id), endpoint], - preferredEndpointId: endpoint.id - } - : {}), - ...(entry.pendingSshAccessOperation - ? { pendingSshAccessOperation: entry.pendingSshAccessOperation } - : {}), - ...(entry.orcadDeployment ? { orcadDeployment: entry.orcadDeployment } : {}), - ...(entry.reconciliation ? { reconciliation: entry.reconciliation } : {}) - }) - return overlaid.success ? overlaid.data : base -} - -/** Replaces one environment's sidecar entry and drops entries whose environment no longer exists. */ -export function writeRuntimeEnvironmentSidecarEntry( - userDataPath: string, - environments: readonly PersistedRuntimeEnvironment[], - environment: PersistedRuntimeEnvironment, - entry: Omit | null -): void { - const current = readRuntimeEnvironmentSidecar(userDataPath) - const live = new Map(environments.map((candidate) => [candidate.id, candidate])) - const entries: Record = {} - for (const [id, existing] of Object.entries(current.entries)) { - const owner = live.get(id) - if (id !== environment.id && owner && isCurrentEntry(owner, existing)) { - entries[id] = existing - } - } - const basePairingRevision = environment.pairingRevision ?? environment.createdAt - const hasState = - entry !== null && - ((entry.runtimeId !== undefined && environment.runtimeId === null) || - entry.sshAccess !== undefined || - entry.pendingSshAccessOperation !== undefined || - entry.orcadDeployment !== undefined || - entry.reconciliation !== undefined || - (entry.pairingRevisionFloor ?? basePairingRevision) > basePairingRevision) - if (entry && hasState) { - entries[environment.id] = SidecarEntrySchema.parse({ - ...entry, - binding: runtimeEnvironmentSidecarBinding(environment) - }) - } - writeSecureJsonFileWithinLimit( - getRuntimeEnvironmentSidecarPath(userDataPath), - SidecarSchema.parse({ version: 1, entries }), - MAX_SIDECAR_FILE_BYTES, - { durable: true } - ) -} - -/** The current sidecar entry for an environment, or none when absent or stale. */ -export function readCurrentRuntimeEnvironmentSidecarEntry( - userDataPath: string, - environment: PersistedRuntimeEnvironment -): RuntimeEnvironmentSidecarEntry | undefined { - const entry = readRuntimeEnvironmentSidecar(userDataPath).entries[environment.id] - return entry && isCurrentEntry(environment, entry) ? entry : undefined -} diff --git a/src/shared/runtime-environment-ssh-access-store.test.ts b/src/shared/runtime-environment-ssh-access-store.test.ts deleted file mode 100644 index b51adc71b8f..00000000000 --- a/src/shared/runtime-environment-ssh-access-store.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { encodePairingOffer } from './pairing' -import { - addEnvironmentFromPairingCode, - getEnvironmentStorePath, - listEnvironments, - markEnvironmentUsed, - removeEnvironment, - updateEnvironmentFromPairingCode -} from './runtime-environment-store' -import { getPreferredPairingOffer, PersistedRuntimeEnvironmentSchema } from './runtime-environments' -import { - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink, - prepareRuntimeEnvironmentSshAccessUnlink, - completeRuntimeEnvironmentSshAccessUnlink, - cancelRuntimeEnvironmentSshAccessLink -} from './runtime-environment-ssh-access-store' -import { z } from 'zod' - -const pairing = { - v: 2 as const, - endpoint: 'wss://server.example/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'private-device-token', - pairedDeviceId: 'paired-client' -} -const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 } -const verifiedPairing = { ...pairing, endpoint: 'ws://127.0.0.1:41000/runtime' } - -describe('independent paired runtime SSH access persistence', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-ssh-access-store-')) - }) - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function seed() { - return addEnvironmentFromPairingCode(userDataPath, { - name: 'Independent host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - } - - function prepare(expectedEnvironment = seed(), requestId = 'link-request') { - return prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment, - requestId, - ...tunnel, - targetFingerprint: 'target-fingerprint' - }) - } - - function link(expectedEnvironment = seed()) { - return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepare(expectedEnvironment), - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing, - tunnel, - now: 90 - }) - } - - function unlink(expectedEnvironment: ReturnType, now?: number) { - const prepared = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment, - requestId: 'unlink-request', - now - }) - return completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'unlink-request' - }) - } - - it('adds access to the same host without deployment ownership and restores its original endpoint', () => { - const original = seed() - const linked = link(original) - expect(listEnvironments(userDataPath)).toEqual([linked]) - expect(linked.id).toBe(original.id) - expect(linked.name).toBe(original.name) - expect(linked.createdAt).toBe(original.createdAt) - expect(linked.runtimeId).toBe('host-runtime') - expect(linked.pairingRevision).toBe(101) - expect(linked.endpoints).toHaveLength(2) - expect(linked.endpoints[0]).toEqual(original.endpoints[0]) - expect(getPreferredPairingOffer(linked)).toEqual(verifiedPairing) - - const unlinked = unlink(linked, 80) - expect(unlinked.id).toBe(original.id) - expect(unlinked.runtimeId).toBe('host-runtime') - expect(unlinked.pairingRevision).toBe(102) - expect(unlinked.endpoints).toEqual(original.endpoints) - expect(unlinked.preferredEndpointId).toBe(original.preferredEndpointId) - expect(unlinked.sshAccess).toBeUndefined() - expect(unlinked.connectionDependency).toBeUndefined() - expect(listEnvironments(userDataPath)).toEqual([unlinked]) - }) - - it('refuses a link whose verification raced with re-pairing', () => { - const original = seed() - const replaced = updateEnvironmentFromPairingCode(userDataPath, original.id, { - pairingCode: encodePairingOffer({ ...pairing, deviceToken: 'replacement-token' }), - now: 100 - }) - expect(() => link(original)).toThrow('changed while SSH access') - expect(listEnvironments(userDataPath)).toEqual([replaced]) - }) - - it('permits unrelated last-used timestamp updates during verification', () => { - const original = seed() - markEnvironmentUsed(userDataPath, original.id, { now: 200 }) - expect(link(original).lastUsedAt).toBe(200) - }) - - it('keeps orca-environments.json in the shape shipped builds read while SSH access is linked', () => { - const original = seed() - const shippedEnvelope = z - .object({ - version: z.literal(1), - environments: z.array(PersistedRuntimeEnvironmentSchema.strict()) - }) - .strict() - const readEnvelope = () => - JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - const linked = link(original) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - expect(readEnvelope().environments[0]).toEqual(original) - markEnvironmentUsed(userDataPath, linked.id, { now: 500 }) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - expect(listEnvironments(userDataPath)[0]?.sshAccess).toEqual(linked.sshAccess) - unlink(linked) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - }) - - it.each([ - { ...verifiedPairing, publicKeyB64: Buffer.alloc(32, 2).toString('base64') }, - { ...verifiedPairing, deviceToken: 'another-token' }, - { ...verifiedPairing, pairedDeviceId: 'another-client' } - ])('refuses a different authenticated host or pairing grant', (wrongPairing) => { - const original = seed() - const prepared = prepare(original) - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing: wrongPairing, - tunnel - }) - ).toThrow('did not verify') - expect(listEnvironments(userDataPath)).toEqual([prepared]) - }) - - it('refuses changing a known execution runtime identity', () => { - const original = seed() - markEnvironmentUsed(userDataPath, original.id, { runtimeId: 'incumbent-runtime' }) - const current = listEnvironments(userDataPath)[0] - expect(() => link(current)).toThrow('did not verify') - expect(listEnvironments(userDataPath)[0]).toMatchObject({ - ...current, - pendingSshAccessOperation: { operation: 'link' } - }) - }) - - it('refuses a second registration of the same execution runtime', () => { - const original = seed() - const duplicate = addEnvironmentFromPairingCode(userDataPath, { - name: 'Duplicate host', - pairingCode: encodePairingOffer(pairing) - }) - markEnvironmentUsed(userDataPath, duplicate.id, { runtimeId: 'host-runtime' }) - const before = listEnvironments(userDataPath) - expect(() => link(original)).toThrow('registered more than once') - expect( - listEnvironments(userDataPath).find((entry) => entry.id === original.id) - ?.pendingSshAccessOperation?.operation - ).toBe('link') - expect(listEnvironments(userDataPath).find((entry) => entry.id === duplicate.id)).toEqual( - before.find((entry) => entry.id === duplicate.id) - ) - }) - - it('prevents ordinary removal or re-pairing from orphaning SSH access', () => { - const linked = link() - expect(() => removeEnvironment(userDataPath, linked.id)).toThrow('Unlink') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, linked.id, { - pairingCode: encodePairingOffer(pairing) - }) - ).toThrow('Unlink') - expect(() => link(linked)).toThrow('already has SSH access') - expect(listEnvironments(userDataPath)).toEqual([linked]) - }) - - it('does not let a stale unlink remove a subsequently replaced SSH link', () => { - const first = link() - const unlinked = unlink(first) - const replacement = link(unlinked) - expect(() => unlink(first)).toThrow('changed while SSH access') - expect(listEnvironments(userDataPath)).toEqual([replacement]) - }) - - it('persists retryable link intent before target claim and fences pairing mutations', () => { - const original = seed() - const prepared = prepare(original) - expect(prepared.endpoints).toEqual(original.endpoints) - expect(prepared.sshAccess).toBeUndefined() - expect(listEnvironments(userDataPath)).toEqual([prepared]) - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')).version).toBe(1) - expect(prepare(original)).toEqual(prepared) - expect(prepare(prepared)).toEqual(prepared) - expect(() => prepare(prepared, 'other-request')).toThrow('Another SSH access') - expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, prepared.id, { - pairingCode: encodePairingOffer(pairing) - }) - ).toThrow('Unlink') - expect(() => - markEnvironmentUsed(userDataPath, prepared.id, { runtimeId: 'different-runtime' }) - ).toThrow('cannot change') - }) - - it('completes a matching verified link atomically and handles lost completion responses', () => { - const prepared = prepare() - const args = { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing, - tunnel - } - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { ...args, requestId: 'stale-request' }) - ).toThrow('pending link intent') - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - ...args, - tunnel: { ...tunnel, sshTargetGeneration: 4 } - }) - ).toThrow('pending link intent') - const linked = linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args) - expect(linked.pendingSshAccessOperation).toBeUndefined() - expect(linked.sshAccess?.requestId).toBe('link-request') - expect(linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args)).toEqual(linked) - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - ...args, - verifiedRuntimeId: 'impostor' - }) - ).toThrow('completed link') - }) - - it('keeps a durable release intent after restoring the endpoint until exact completion', () => { - const linked = link() - const prepared = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: linked, - requestId: 'release-request' - }) - expect(prepared.sshAccess).toBeUndefined() - expect(getPreferredPairingOffer(prepared)).toEqual(pairing) - expect(prepared.pendingSshAccessOperation).toMatchObject({ - operation: 'unlink', - requestId: 'release-request', - sshTargetId: tunnel.sshTargetId, - sshTargetGeneration: tunnel.sshTargetGeneration - }) - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')).version).toBe(1) - expect( - prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'release-request' - }) - ).toEqual(prepared) - expect(() => - completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'stale-request' - }) - ).toThrow('pending unlink intent') - expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink') - const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'release-request' - }) - expect(finished.pendingSshAccessOperation).toBeUndefined() - }) - - it('turns failed verification into durable release intent without ever publishing access', () => { - const prepared = prepare() - expect(() => - cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'wrong' - }) - ).toThrow('pending link intent') - const cancelling = cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request' - }) - expect(cancelling.endpoints).toEqual(prepared.endpoints) - expect(cancelling.pendingSshAccessOperation?.operation).toBe('unlink') - expect(cancelling.sshAccess).toBeUndefined() - expect( - cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: cancelling, - requestId: 'link-request' - }) - ).toEqual(cancelling) - const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: cancelling, - requestId: 'link-request' - }) - expect(finished.pendingSshAccessOperation).toBeUndefined() - }) -}) diff --git a/src/shared/runtime-environment-ssh-access-store.ts b/src/shared/runtime-environment-ssh-access-store.ts deleted file mode 100644 index db14401223c..00000000000 --- a/src/shared/runtime-environment-ssh-access-store.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { runtimeEnvironmentSshAccessBinding } from './runtime-environment-authority-binding' -export { runtimeEnvironmentSshAccessBinding } from './runtime-environment-authority-binding' -import type { PairingOffer } from './pairing' -import { - getPreferredPairingOffer, - RuntimeSshAccessOperationSchema, - type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment, - type RuntimeSshAccessOperation, - type RuntimeSshTunnelLink -} from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError -} from './runtime-environment-store-file' -import { - overlayRuntimeEnvironmentSidecar, - readCurrentRuntimeEnvironmentSidecarEntry, - runtimeEnvironmentSidecarBinding, - writeRuntimeEnvironmentSidecarEntry, - type RuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' - -type SidecarState = Omit - -type AccessContext = { - environments: PersistedRuntimeEnvironment[] - persisted: PersistedRuntimeEnvironment - entry: SidecarState - view: KnownRuntimeEnvironment -} - -export function prepareRuntimeEnvironmentSshAccessLink( - userDataPath: string, - args: { - expectedEnvironment: KnownRuntimeEnvironment - requestId: string - sshTargetId: string - sshTargetGeneration: number - remotePort: number - targetFingerprint: string - } -): KnownRuntimeEnvironment { - const { expectedEnvironment: _expected, ...fields } = args - const intent = RuntimeSshAccessOperationSchema.parse({ ...fields, operation: 'link' }) - const context = readAccessContext(userDataPath, args.expectedEnvironment, true) - const existing = context.view - if (existing.pendingSshAccessOperation) { - requireMatchingIntent(existing, intent) - return existing - } - if (existing.sshAccess) { - throw invalid('This server already has SSH access.') - } - if (existing.connectionDependency) { - throw invalid('Unlink the existing external tunnel before adding SSH access.') - } - return commit(userDataPath, context, { ...context.entry, pendingSshAccessOperation: intent }) -} - -export function linkVerifiedRuntimeEnvironmentSshAccess( - userDataPath: string, - args: { - expectedEnvironment: KnownRuntimeEnvironment - requestId: string - verifiedRuntimeId: string - verifiedPairing: PairingOffer - tunnel: RuntimeSshTunnelLink - now?: number - } -): KnownRuntimeEnvironment { - const views = readEnvironmentStore(userDataPath).environments - const completed = views.find((entry) => entry.id === args.expectedEnvironment.id) - if (completed?.sshAccess?.requestId && completed.sshAccess.requestId === args.requestId) { - return requireMatchingCompletedLink(completed, args) - } - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const existing = context.view - const intent = existing.pendingSshAccessOperation - if ( - !intent || - intent.operation !== 'link' || - intent.requestId !== args.requestId || - intent.sshTargetId !== args.tunnel.sshTargetId || - intent.sshTargetGeneration !== args.tunnel.sshTargetGeneration || - intent.remotePort !== args.tunnel.remotePort - ) { - throw invalid('SSH access completion does not match its pending link intent.') - } - const offer = getPreferredPairingOffer(existing) - if ( - !args.verifiedRuntimeId.trim() || - (existing.runtimeId !== null && existing.runtimeId !== args.verifiedRuntimeId) || - args.verifiedPairing.publicKeyB64 !== offer.publicKeyB64 || - args.verifiedPairing.deviceToken !== offer.deviceToken || - args.verifiedPairing.pairedDeviceId !== offer.pairedDeviceId - ) { - throw invalid('The SSH endpoint did not verify as this paired server.') - } - if ( - views.some((entry) => entry.id !== existing.id && entry.runtimeId === args.verifiedRuntimeId) - ) { - throw invalid( - 'This runtime is registered more than once. Reconcile its existing registrations first.' - ) - } - const endpointId = `ssh-${randomUUID()}` - const { pendingSshAccessOperation: _intent, ...remaining } = context.entry - return commit(userDataPath, context, { - ...remaining, - pairingRevisionFloor: nextPairingRevision(existing, args.now ?? Date.now()), - runtimeId: args.verifiedRuntimeId, - sshAccess: { - ...args.tunnel, - requestId: intent.requestId, - targetFingerprint: intent.targetFingerprint, - endpointId, - previousPreferredEndpointId: existing.preferredEndpointId, - endpoint: { - id: endpointId, - kind: 'websocket', - label: 'SSH tunnel', - endpoint: args.verifiedPairing.endpoint, - publicKeyB64: offer.publicKeyB64, - deviceToken: offer.deviceToken - } - } - }) -} - -export function prepareRuntimeEnvironmentSshAccessUnlink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string; now?: number } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const existing = context.view - if (existing.pendingSshAccessOperation) { - if ( - existing.pendingSshAccessOperation.operation === 'unlink' && - existing.pendingSshAccessOperation.requestId === args.requestId - ) { - return existing - } - throw invalid('Another SSH access operation is pending.') - } - const sshAccess = existing.sshAccess - if (!sshAccess) { - throw invalid('This server does not have independently linked SSH access.') - } - const { sshAccess: _access, ...remaining } = context.entry - return commit(userDataPath, context, { - ...remaining, - pairingRevisionFloor: nextPairingRevision(existing, args.now ?? Date.now()), - pendingSshAccessOperation: { - requestId: args.requestId, - operation: 'unlink', - sshTargetId: sshAccess.sshTargetId, - sshTargetGeneration: sshAccess.sshTargetGeneration, - remotePort: sshAccess.remotePort, - targetFingerprint: sshAccess.targetFingerprint - } - }) -} - -export function completeRuntimeEnvironmentSshAccessUnlink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const intent = context.view.pendingSshAccessOperation - if (!intent || intent.operation !== 'unlink' || intent.requestId !== args.requestId) { - throw invalid('SSH access completion does not match its pending unlink intent.') - } - const { pendingSshAccessOperation: _intent, ...remaining } = context.entry - return commit(userDataPath, context, remaining) -} - -export function cancelRuntimeEnvironmentSshAccessLink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const intent = context.view.pendingSshAccessOperation - if (!intent || intent.requestId !== args.requestId) { - throw invalid('SSH access cancellation does not match its pending link intent.') - } - if (intent.operation === 'unlink') { - return context.view - } - return commit(userDataPath, context, { - ...context.entry, - pendingSshAccessOperation: { ...intent, operation: 'unlink' } - }) -} - -/** A retried completion must name exactly the link it already completed. */ -function requireMatchingCompletedLink( - completed: KnownRuntimeEnvironment, - args: Parameters[1] -): KnownRuntimeEnvironment { - const access = completed.sshAccess! - const expectedIntent = args.expectedEnvironment.pendingSshAccessOperation - const prior = expectedIntent - ? { - ...completed, - runtimeId: args.expectedEnvironment.runtimeId, - pairingRevision: args.expectedEnvironment.pairingRevision, - sshAccess: undefined, - connectionDependency: undefined, - pendingSshAccessOperation: expectedIntent, - preferredEndpointId: access.previousPreferredEndpointId, - endpoints: completed.endpoints.filter((entry) => entry.id !== access.endpointId) - } - : completed - requireUnchangedEnvironment([prior], args.expectedEnvironment) - const offer = getPreferredPairingOffer(completed) - if ( - completed.runtimeId !== args.verifiedRuntimeId || - offer.endpoint !== args.verifiedPairing.endpoint || - offer.deviceToken !== args.verifiedPairing.deviceToken || - offer.publicKeyB64 !== args.verifiedPairing.publicKeyB64 || - offer.pairedDeviceId !== args.verifiedPairing.pairedDeviceId || - access.sshTargetId !== args.tunnel.sshTargetId || - access.sshTargetGeneration !== args.tunnel.sshTargetGeneration || - access.localPort !== args.tunnel.localPort || - access.remotePort !== args.tunnel.remotePort || - (expectedIntent && - (expectedIntent.operation !== 'link' || - expectedIntent.requestId !== args.requestId || - expectedIntent.targetFingerprint !== access.targetFingerprint)) - ) { - throw invalid('SSH access retry does not match its completed link.') - } - return completed -} - -function readAccessContext( - userDataPath: string, - expected: KnownRuntimeEnvironment, - ignoreIntent = false -): AccessContext { - const { environments } = readPersistedEnvironmentStore(userDataPath) - const views = readEnvironmentStore(userDataPath).environments - const view = requireUnchangedEnvironment(views, expected, ignoreIntent) - const persisted = environments.find((entry) => entry.id === view.id)! - const current = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, persisted) - const { binding: _binding, ...entry } = current ?? { binding: undefined } - return { environments, persisted, entry, view } -} - -/** Writes only after the overlay proves every requested field survives validation. */ -function commit( - userDataPath: string, - context: AccessContext, - next: SidecarState -): KnownRuntimeEnvironment { - const view = overlayRuntimeEnvironmentSidecar(context.persisted, { - ...next, - binding: runtimeEnvironmentSidecarBinding(context.persisted) - }) - if ( - (next.sshAccess !== undefined) !== (view.sshAccess !== undefined) || - (next.pendingSshAccessOperation !== undefined) !== - (view.pendingSshAccessOperation !== undefined) - ) { - throw invalid('The SSH access state is inconsistent with this paired server.') - } - writeRuntimeEnvironmentSidecarEntry(userDataPath, context.environments, context.persisted, next) - return view -} - -function requireMatchingIntent( - environment: KnownRuntimeEnvironment, - intent: RuntimeSshAccessOperation -): void { - if (JSON.stringify(environment.pendingSshAccessOperation) !== JSON.stringify(intent)) { - throw invalid('Another SSH access operation is pending.') - } -} - -function requireUnchangedEnvironment( - environments: KnownRuntimeEnvironment[], - expected: KnownRuntimeEnvironment, - ignoreIntent = false -): KnownRuntimeEnvironment { - const existing = environments.find((entry) => entry.id === expected.id) - if ( - !existing || - JSON.stringify(runtimeEnvironmentSshAccessBinding(existing, ignoreIntent)) !== - JSON.stringify(runtimeEnvironmentSshAccessBinding(expected, ignoreIntent)) - ) { - throw invalid( - 'The paired server changed while SSH access was being verified. Retry with its current pairing.' - ) - } - return existing -} - -function nextPairingRevision(environment: KnownRuntimeEnvironment, now: number): number { - return Math.max(now, (environment.pairingRevision ?? environment.createdAt) + 1) -} - -function invalid(message: string): RuntimeEnvironmentStoreError { - return new RuntimeEnvironmentStoreError('invalid_argument', message) -} diff --git a/src/shared/runtime-environment-store-file.ts b/src/shared/runtime-environment-store-file.ts deleted file mode 100644 index a69a3fe22ea..00000000000 --- a/src/shared/runtime-environment-store-file.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { - PersistedRuntimeEnvironmentSchema, - RuntimeEnvironmentStoreSchema, - type KnownRuntimeEnvironment, - type RuntimeEnvironmentStore -} from './runtime-environments' -import { - overlayRuntimeEnvironmentSidecar, - readRuntimeEnvironmentSidecar, - RuntimeEnvironmentSidecarInvalidError -} from './runtime-environment-sidecar' -import { hardenExistingSecureFile } from './secure-file' - -const ENVIRONMENTS_FILE = 'orca-environments.json' -export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024 - -export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' - -export class RuntimeEnvironmentStoreError extends Error { - readonly code: RuntimeEnvironmentStoreErrorCode - - constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) { - super(message) - this.name = 'RuntimeEnvironmentStoreError' - this.code = code - } -} - -export function getEnvironmentStorePath(userDataPath: string): string { - return join(userDataPath, ENVIRONMENTS_FILE) -} - -/** The orca-environments.json content, exactly as shipped builds read and rewrite it. */ -export function readPersistedEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore { - const path = getEnvironmentStorePath(userDataPath) - if (!existsSync(path)) { - return { version: 1, environments: [] } - } - try { - hardenExistingSecureFile(path) - const parsed = RuntimeEnvironmentStoreSchema.parse( - JSON.parse( - readNodeFileSyncWithinLimit(path, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) - ) - return { - version: 1, - environments: parsed.environments - .map((entry) => PersistedRuntimeEnvironmentSchema.parse(entry)) - .sort((a, b) => a.name.localeCompare(b.name)) - } - } catch { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - `Could not read Orca environments at ${path}; the file is invalid.` - ) - } -} - -/** Persisted environments with their sidecar state overlaid; stale sidecar entries are ignored. */ -export function readEnvironmentStore(userDataPath: string): { - version: 1 - environments: KnownRuntimeEnvironment[] -} { - const store = readPersistedEnvironmentStore(userDataPath) - let sidecar: ReturnType - try { - sidecar = readRuntimeEnvironmentSidecar(userDataPath) - } catch (error) { - // Fail closed like the main file: a link we cannot read may pin a host key we must not drop. - if (error instanceof RuntimeEnvironmentSidecarInvalidError) { - throw new RuntimeEnvironmentStoreError('runtime_error', error.message) - } - throw error - } - return { - version: 1, - environments: store.environments.map((environment) => - overlayRuntimeEnvironmentSidecar(environment, sidecar.entries[environment.id]) - ) - } -} - -/** Writes only persisted fields; sidecar state is written by the sidecar module. */ -export function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { - const path = getEnvironmentStorePath(userDataPath) - try { - writeSecureJsonFileWithinLimit( - path, - RuntimeEnvironmentStoreSchema.parse(store), - MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES - ) - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - `Could not write Orca environments at ${path}; the store exceeds its durable capacity.` - ) - } - throw error - } -} diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index b4e2ac83d22..e784dc80281 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -1,27 +1,40 @@ import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' +import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' import { parsePairingCode, type PairingOffer } from './pairing' import { classifyRemotePairingHostname } from './remote-pairing-address' +import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' +import { hardenExistingSecureFile } from './secure-file' import { createEnvironmentFromPairingOffer, getPreferredPairingOffer, + KnownRuntimeEnvironmentSchema, + RuntimeEnvironmentStoreSchema, type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment, - type RuntimeEnvironmentSource + type RuntimeEnvironmentSource, + type RuntimeEnvironmentStore } from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError, - writeEnvironmentStore -} from './runtime-environment-store-file' -import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar' -export { - getEnvironmentStorePath, - MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES, - RuntimeEnvironmentStoreError, - type RuntimeEnvironmentStoreErrorCode -} from './runtime-environment-store-file' +const ENVIRONMENTS_FILE = 'orca-environments.json' +export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024 + +export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' + +export class RuntimeEnvironmentStoreError extends Error { + readonly code: RuntimeEnvironmentStoreErrorCode + + constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) { + super(message) + this.name = 'RuntimeEnvironmentStoreError' + this.code = code + } +} + +export function getEnvironmentStorePath(userDataPath: string): string { + return join(userDataPath, ENVIRONMENTS_FILE) +} export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[] { return readEnvironmentStore(userDataPath).environments @@ -44,7 +57,7 @@ export function addEnvironmentFromPairingCode( 'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const now = args.now ?? Date.now() const existing = store.environments.find((entry) => entry.name === args.name) if (existing) { @@ -74,14 +87,12 @@ export function addEnvironmentFromPairingCode( } export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment { - const environment = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector) - assertNoIndependentSshAccess(environment) - const store = readPersistedEnvironmentStore(userDataPath) - const persisted = resolveEnvironmentFromStore(store, environment.id) - const remaining = store.environments.filter((entry) => entry.id !== environment.id) - writeEnvironmentStore(userDataPath, { version: 1, environments: remaining }) - // A leftover entry would read as stale anyway; dropping it keeps the sidecar from growing. - writeRuntimeEnvironmentSidecarEntry(userDataPath, remaining, persisted, null) + const store = readEnvironmentStore(userDataPath) + const environment = resolveEnvironmentFromStore(store, selector) + writeEnvironmentStore(userDataPath, { + version: 1, + environments: store.environments.filter((entry) => entry.id !== environment.id) + }) return environment } @@ -97,10 +108,7 @@ export function updateEnvironmentFromPairingCode( 'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } - assertNoIndependentSshAccess( - resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector) - ) - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const existing = resolveEnvironmentFromStore(store, selector) const now = args.now ?? Date.now() const previousPairingRevision = existing.pairingRevision ?? existing.createdAt @@ -170,7 +178,7 @@ export function markEnvironmentUsed( selector: string, args: { runtimeId?: string | null; pairedDeviceId?: string; now?: number } = {} ): void { - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const environment = resolveEnvironmentFromStore(store, selector) const now = args.now ?? Date.now() const runtimeIdChanged = args.runtimeId != null && args.runtimeId !== environment.runtimeId @@ -183,15 +191,6 @@ export function markEnvironmentUsed( if (!runtimeIdChanged && !pairedDeviceIdChanged && lastUsedIsFresh) { return } - if (runtimeIdChanged || pairedDeviceIdChanged) { - const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environment.id) - if (current.sshAccess || current.pendingSshAccessOperation) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'SSH access operation cannot change the paired runtime identity.' - ) - } - } const next = store.environments.map((entry) => entry.id === environment.id ? { @@ -206,10 +205,10 @@ export function markEnvironmentUsed( writeEnvironmentStore(userDataPath, { version: 1, environments: next }) } -export function resolveEnvironmentFromStore( - store: { environments: T[] }, +function resolveEnvironmentFromStore( + store: RuntimeEnvironmentStore, selector: string -): T { +): KnownRuntimeEnvironment { const byId = store.environments.find((entry) => entry.id === selector) if (byId) { return byId @@ -227,17 +226,49 @@ export function resolveEnvironmentFromStore KnownRuntimeEnvironmentSchema.parse(entry)) + .sort((a, b) => a.name.localeCompare(b.name)) + } + } catch { throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - "Unlink this server's SSH access before replacing its pairing or removing it." + 'runtime_error', + `Could not read Orca environments at ${path}; the file is invalid.` ) } } + +function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { + const path = getEnvironmentStorePath(userDataPath) + try { + writeSecureJsonFileWithinLimit( + path, + RuntimeEnvironmentStoreSchema.parse(store), + MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES + ) + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + throw new RuntimeEnvironmentStoreError( + 'runtime_error', + `Could not write Orca environments at ${path}; the store exceeds its durable capacity.` + ) + } + throw error + } +} diff --git a/src/shared/runtime-environments-ssh-access.test.ts b/src/shared/runtime-environments-ssh-access.test.ts deleted file mode 100644 index d2ec327c151..00000000000 --- a/src/shared/runtime-environments-ssh-access.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing' -import { - createEnvironmentFromPairingOffer, - getPreferredPairingOffer, - getRuntimeSshAccess, - KnownRuntimeEnvironmentSchema, - redactRuntimeEnvironment, - RuntimeEnvironmentStoreSchema, - type RuntimeSshTunnelLink -} from './runtime-environments' - -const link: RuntimeSshTunnelLink = { - sshTargetId: 'ssh-host', - sshTargetGeneration: 7, - localPort: 46768, - remotePort: 6768 -} -const accessLink = { - ...link, - endpointId: 'ssh-access', - previousPreferredEndpointId: 'ws-environment-1' -} -const offer: PairingOffer = { - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'secret-device-token', - publicKeyB64: 'secret-key', - pairedDeviceId: 'paired-device' -} -function accessEnvironment() { - const original = environment() - return { - ...original, - connectionDependency: 'ssh-tunnel' as const, - sshAccess: accessLink, - endpoints: [...original.endpoints, { ...original.endpoints[0]!, id: accessLink.endpointId }], - preferredEndpointId: accessLink.endpointId - } -} -function environment() { - return createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Host', - now: 1, - offer, - runtimeId: 'host-runtime' - }) -} - -describe('runtime SSH access without deployment ownership', () => { - it('keeps old stored environments valid without adding an SSH dependency', () => { - const original = environment() - const restored = RuntimeEnvironmentStoreSchema.parse({ version: 1, environments: [original] }) - .environments[0]! - expect(restored).toEqual(original) - expect(getRuntimeSshAccess(KnownRuntimeEnvironmentSchema.parse(restored))).toBeUndefined() - expect(restored).not.toHaveProperty('sshAccess') - // The persisted envelope never carries sidecar state, even when handed a linked environment. - const persisted = RuntimeEnvironmentStoreSchema.parse({ - version: 1, - environments: [accessEnvironment()] - }).environments[0]! - expect(persisted).not.toHaveProperty('sshAccess') - }) - - it.each([ - { sshTargetId: '' }, - { sshTargetGeneration: 0 }, - { sshTargetGeneration: 1.5 }, - { localPort: 0 }, - { localPort: 65536 }, - { remotePort: 0 }, - { remotePort: 65536 } - ])('rejects malformed generic access links: %j', (change) => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - sshAccess: { ...accessLink, ...change } - }).success - ).toBe(false) - }) - - it('preserves SSH metadata while redacting the same pairing secrets', () => { - const accessed = KnownRuntimeEnvironmentSchema.parse(accessEnvironment()) - const redacted = redactRuntimeEnvironment(accessed) - expect(getRuntimeSshAccess(redacted)).toEqual(accessLink) - expect(redacted.endpoints[0]).not.toHaveProperty('deviceToken') - expect(redacted.endpoints[0]).not.toHaveProperty('publicKeyB64') - expect(JSON.stringify(redacted)).not.toContain('secret-') - expect(getPreferredPairingOffer(accessed)).toEqual(offer) - }) - - it.each([ - { endpointId: 'missing' }, - { previousPreferredEndpointId: 'missing' }, - { previousPreferredEndpointId: 'ssh-access' }, - { localPort: 46769 } - ])('rejects access links that cannot restore the prior endpoint: %j', (change) => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - sshAccess: { ...accessLink, ...change } - }).success - ).toBe(false) - }) - - it('rejects an unpreferred SSH access endpoint', () => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - preferredEndpointId: accessLink.previousPreferredEndpointId - }).success - ).toBe(false) - }) - - it('rejects a non-loopback SSH access endpoint', () => { - const accessed = accessEnvironment() - accessed.endpoints[1]!.endpoint = 'ws://remote.example:46768' - expect(KnownRuntimeEnvironmentSchema.safeParse(accessed).success).toBe(false) - }) - - it('rejects a generic access link without its SSH dependency', () => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - connectionDependency: undefined - }).success - ).toBe(false) - }) - - it('rejects a non-WebSocket loopback endpoint', () => { - const accessed = accessEnvironment() - accessed.endpoints[1]!.endpoint = 'https://127.0.0.1:46768' - expect(KnownRuntimeEnvironmentSchema.safeParse(accessed).success).toBe(false) - }) -}) diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index a7f19dd9788..b3411eb3f70 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -1,7 +1,5 @@ import { z } from 'zod' import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing' -import { classifyRemotePairingHostname } from './remote-pairing-address' -import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record' export const RuntimeAccessEndpointSchema = z.object({ id: z.string().min(1), @@ -22,42 +20,7 @@ export type PublicRuntimeAccessEndpoint = z.infer -export const RuntimeSshTunnelLinkSchema = z.object({ - sshTargetId: z.string().min(1), - sshTargetGeneration: z.number().int().positive(), - localPort: z.number().int().min(1).max(65_535), - remotePort: z.number().int().min(1).max(65_535) -}) - -export type RuntimeSshTunnelLink = z.infer - -/** A server Orca deployed and owns over SSH; unlike sshAccess, it grants lifecycle ownership. */ -export const OrcadDeploymentLinkSchema = RuntimeSshTunnelLinkSchema -export type OrcadDeploymentLink = RuntimeSshTunnelLink - -export const RuntimeSshAccessLinkSchema = RuntimeSshTunnelLinkSchema.extend({ - requestId: z.string().min(1).optional(), - targetFingerprint: z.string().min(1).optional(), - endpointId: z.string().min(1), - previousPreferredEndpointId: z.string().min(1) -}) - -export type RuntimeSshAccessLink = z.infer - -export const RuntimeSshAccessOperationSchema = RuntimeSshTunnelLinkSchema.omit({ localPort: true }) - .extend({ - requestId: z.string().min(1), - operation: z.enum(['link', 'unlink']), - targetFingerprint: z.string().min(1).optional() - }) - .refine((intent) => intent.operation !== 'link' || !!intent.targetFingerprint, { - message: 'Link intent requires a target fingerprint.' - }) - -export type RuntimeSshAccessOperation = z.infer - -/** The fields shipped builds read and rewrite in orca-environments.json. */ -export const PersistedRuntimeEnvironmentSchema = z.object({ +export const KnownRuntimeEnvironmentSchema = z.object({ id: z.string().min(1), name: z.string().min(1), createdAt: z.number().finite(), @@ -72,63 +35,6 @@ export const PersistedRuntimeEnvironmentSchema = z.object({ preferredEndpointId: z.string().min(1) }) -export type PersistedRuntimeEnvironment = z.infer - -/** - * A persisted environment with its sidecar state overlaid (runtime-environment-sidecar). These - * fields never enter orca-environments.json: shipped builds strip unknown keys when they rewrite it. - */ -export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.extend({ - sshAccess: RuntimeSshAccessLinkSchema.optional(), - pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), - orcadDeployment: OrcadDeploymentLinkSchema.optional(), - reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() -}) - .refine( - ({ pendingSshAccessOperation, sshAccess, orcadDeployment, connectionDependency }) => - !pendingSshAccessOperation || (!sshAccess && !orcadDeployment && !connectionDependency), - { - message: 'Pending SSH access operations cannot coexist with active SSH access.', - path: ['pendingSshAccessOperation'] - } - ) - .refine( - (environment) => { - const access = environment.sshAccess - return ( - !access || - (environment.connectionDependency === 'ssh-tunnel' && - environment.preferredEndpointId === access.endpointId && - access.previousPreferredEndpointId !== access.endpointId && - environment.endpoints.some( - (endpoint) => endpoint.id === access.previousPreferredEndpointId - ) && - getPreferredLoopbackRuntimePort(environment) === access.localPort) - ) - }, - { - message: - 'Runtime SSH access must preserve its previous endpoint and prefer its loopback endpoint.', - path: ['sshAccess'] - } - ) - .refine( - (environment) => { - const deployment = environment.orcadDeployment - return ( - !deployment || - (!environment.sshAccess && - environment.connectionDependency === 'ssh-tunnel' && - getPreferredLoopbackRuntimePort(environment) === deployment.localPort) - ) - }, - { - message: - 'A managed deployment must prefer its own tunnel and exclude independent SSH access.', - path: ['orcadDeployment'] - } - ) - export type KnownRuntimeEnvironment = z.infer export type PublicKnownRuntimeEnvironment = Omit & { @@ -146,11 +52,9 @@ export function redactRuntimeEnvironment( } } -// Why version 1 and the persisted schema: shipped builds accept only this shape, so every -// T5 field lives in the sidecar and a downgrade rewrite cannot lose or reject it. export const RuntimeEnvironmentStoreSchema = z.object({ version: z.literal(1), - environments: z.array(PersistedRuntimeEnvironmentSchema) + environments: z.array(KnownRuntimeEnvironmentSchema) }) export type RuntimeEnvironmentStore = z.infer @@ -217,35 +121,3 @@ export function getPreferredPairingOffer(environment: KnownRuntimeEnvironment): ...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {}) } } - -/** The tunnel a server is reached through; only orcadDeployment also grants lifecycle ownership. */ -export function getRuntimeSshAccess( - environment: Pick -): RuntimeSshTunnelLink | undefined { - return environment.orcadDeployment ?? environment.sshAccess -} - -export function getPreferredLoopbackRuntimePort(environment: { - endpoints: { id: string; endpoint: string }[] - preferredEndpointId: string -}): number | null { - const endpoint = environment.endpoints.find( - (entry) => entry.id === environment.preferredEndpointId - ) - if (!endpoint) { - return null - } - try { - const url = new URL(endpoint.endpoint) - const port = Number(url.port) - return (url.protocol === 'ws:' || url.protocol === 'wss:') && - classifyRemotePairingHostname(url.hostname) === 'loopback' && - Number.isInteger(port) && - port >= 1 && - port <= 65_535 - ? port - : null - } catch { - return null - } -} diff --git a/src/shared/runtime-rpc-call-queue-retirement.test.ts b/src/shared/runtime-rpc-call-queue-retirement.test.ts deleted file mode 100644 index 3958c200991..00000000000 --- a/src/shared/runtime-rpc-call-queue-retirement.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeRpcCallQueueBusyError, RuntimeRpcCallQueuePool } from './runtime-rpc-call-queue' - -it('refuses an active or queued selector without interrupting or replaying its calls', async () => { - const queue = new RuntimeRpcCallQueuePool(1, 1) - const pending = Promise.withResolvers() - const first = queue.enqueue('host', 'terminal.send', () => pending.promise) - const secondRun = vi.fn(async () => 'second-ack') - const second = queue.enqueue('host', 'terminal.send', secondRun) - expect(() => queue.holdIdleSelectors(['host'])).toThrow(RuntimeRpcCallQueueBusyError) - pending.resolve('first-ack') - await expect(first).resolves.toBe('first-ack') - await expect(second).resolves.toBe('second-ack') - expect(secondRun).toHaveBeenCalledOnce() - await vi.waitFor(() => queue.holdIdleSelectors(['host'])()) -}) - -it('holds both identities, refuses new calls before dispatch, and leaves other hosts running', async () => { - const queue = new RuntimeRpcCallQueuePool() - const release = queue.holdIdleSelectors(['canonical', 'historical', 'canonical']) - const run = vi.fn(async () => 'ack') - for (const id of ['canonical', 'historical']) { - await expect(queue.enqueue(id, 'terminal.send', run)).rejects.toMatchObject({ - code: 'runtime_rpc_queue_busy' - }) - } - expect(run).not.toHaveBeenCalled() - await expect(queue.enqueue('other', 'terminal.send', run)).resolves.toBe('ack') - release() - await expect(queue.enqueue('historical', 'terminal.send', run)).resolves.toBe('ack') - expect(run).toHaveBeenCalledTimes(2) -}) - -it('acquires a group atomically without retaining a partial hold on failure', async () => { - const queue = new RuntimeRpcCallQueuePool() - const release = queue.holdIdleSelectors(['historical']) - expect(() => queue.holdIdleSelectors(['canonical', 'historical'])).toThrow( - RuntimeRpcCallQueueBusyError - ) - await expect(queue.enqueue('canonical', 'repo.list', async () => 'ok')).resolves.toBe('ok') - release() -}) - -it('does not let an old release clear a newer hold', async () => { - const queue = new RuntimeRpcCallQueuePool() - const first = queue.holdIdleSelectors(['host']) - first() - const second = queue.holdIdleSelectors(['host']) - first() - await expect(queue.enqueue('host', 'repo.list', async () => 'ok')).rejects.toThrow( - RuntimeRpcCallQueueBusyError - ) - second() - await expect(queue.enqueue('host', 'repo.list', async () => 'ok')).resolves.toBe('ok') -}) - -it('counts a selector busy while only its long-wait lane has a call', async () => { - const queue = new RuntimeRpcCallQueuePool() - const pending = Promise.withResolvers() - const removal = queue.enqueue('host', 'worktree.rm', () => pending.promise) - expect(() => queue.holdIdleSelectors(['host'])).toThrow(RuntimeRpcCallQueueBusyError) - pending.resolve('removed') - await expect(removal).resolves.toBe('removed') - await vi.waitFor(() => queue.holdIdleSelectors(['host'])()) -}) diff --git a/src/shared/runtime-rpc-call-queue.ts b/src/shared/runtime-rpc-call-queue.ts index 0503c5e06c0..132c8099273 100644 --- a/src/shared/runtime-rpc-call-queue.ts +++ b/src/shared/runtime-rpc-call-queue.ts @@ -16,15 +16,6 @@ export class RuntimeRpcCallQueueOverloadError extends Error { } } -export class RuntimeRpcCallQueueBusyError extends Error { - readonly code = 'runtime_rpc_queue_busy' - - constructor() { - super('Runtime calls are active or their routing is changing; retry after they settle.') - this.name = 'RuntimeRpcCallQueueBusyError' - } -} - type QueuedRuntimeCall = { background: boolean retainedBytes: number @@ -66,13 +57,8 @@ function isLongWaitRuntimeMethod(method: string): boolean { return method === 'worktree.rm' } -function longWaitQueueKey(selector: string): string { - return `${selector}\u0000long-wait` -} - export class RuntimeRpcCallQueuePool { private readonly queues = new Map() - private readonly heldSelectors = new Set() private queuedCallCount = 0 private retainedCallBytes = 0 @@ -84,32 +70,6 @@ export class RuntimeRpcCallQueuePool { private readonly maxRetainedBytes = REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES ) {} - /** Acquires all idle selectors atomically; release never replays refused calls. */ - holdIdleSelectors(selectors: readonly string[]): () => void { - const unique = [...new Set(selectors)] - // A selector's long-wait lane counts too: holding it must see every call still in flight. - const busy = (selector: string): boolean => - this.heldSelectors.has(selector) || - this.queues.has(selector) || - this.queues.has(longWaitQueueKey(selector)) - if (unique.some(busy)) { - throw new RuntimeRpcCallQueueBusyError() - } - for (const selector of unique) { - this.heldSelectors.add(selector) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const selector of unique) { - this.heldSelectors.delete(selector) - } - } - } - enqueue( selector: string, method: string, @@ -120,11 +80,8 @@ export class RuntimeRpcCallQueuePool { if (signal?.aborted) { return Promise.reject(abortSignalReason(signal)) } - if (this.heldSelectors.has(selector)) { - return Promise.reject(new RuntimeRpcCallQueueBusyError()) - } // Same concurrency bound, counted apart from the selector's other calls; global caps still apply. - const queueKey = isLongWaitRuntimeMethod(method) ? longWaitQueueKey(selector) : selector + const queueKey = isLongWaitRuntimeMethod(method) ? `${selector}\u0000long-wait` : selector if (this.queuedCallCount >= this.maxQueuedTotal) { return Promise.reject(new RuntimeRpcCallQueueOverloadError('global')) } diff --git a/src/shared/runtime-ssh-access.ts b/src/shared/runtime-ssh-access.ts deleted file mode 100644 index 167810f095a..00000000000 --- a/src/shared/runtime-ssh-access.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod' - -const AccessRequest = z.object({ - selector: z.string().trim().min(1).max(1024), - requestId: z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/) -}) - -export const RuntimeSshAccessLinkRequestSchema = AccessRequest.extend({ - sshTargetId: z.string().trim().min(1).max(1024), - remotePort: z.number().int().min(1).max(65_535) -}).strict() -export const RuntimeSshAccessUnlinkRequestSchema = AccessRequest.strict() - -export type RuntimeSshAccessLinkRequest = z.infer -export type RuntimeSshAccessUnlinkRequest = z.infer diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 285e0e9ac50..983498f92ca 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -1,5 +1,4 @@ import type { SshPendingPtyKill } from './ssh-pending-pty-kill' -import type { OrcadSshProvisioningIntent } from './orcad-ssh-provisioning' // ─── SSH Connection Types ─────────────────────────────────────────── @@ -89,12 +88,10 @@ export type SshTarget = { * re-adopt only, so automations fenced on an old registration cannot run on a * later target that happens to reuse the id. Never advanced by connect state. */ generation?: number - /** Main-owned provisioning intent; never fall back to a relay while it exists. */ - orcadProvisioning?: OrcadSshProvisioningIntent } /** Renderer-authored target fields; registration generations are allocated and owned by main. */ -export type SshTargetCreateInput = Omit +export type SshTargetCreateInput = Omit export type SshTargetUpdateInput = Partial /** Public target identity and observed host metadata safe to mirror to a paired client. */ diff --git a/src/shared/transport-publication-drain.test.ts b/src/shared/transport-publication-drain.test.ts deleted file mode 100644 index e515cf464f6..00000000000 --- a/src/shared/transport-publication-drain.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { TransportPublicationDrain } from './transport-publication-drain' - -describe('TransportPublicationDrain', () => { - it('waits for every tracked write, including writes tracked while draining', async () => { - const publication = new TransportPublicationDrain(() => {}) - const first = publication.trackWrite() - const done = vi.fn() - const drain = publication.drain(new AbortController().signal).then(done) - const second = publication.trackWrite() - first({ ok: true }) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('transport_publication_not_drained') - second({ ok: true }) - await drain - expect(done).toHaveBeenCalledOnce() - }) - - it('ignores a repeated settlement of the same write', async () => { - const publication = new TransportPublicationDrain(() => {}) - const settle = publication.trackWrite() - const other = publication.trackWrite() - settle({ ok: true }) - settle({ ok: true }) - expect(() => publication.assertDrained()).toThrow('transport_publication_not_drained') - other({ ok: true }) - await publication.drain(new AbortController().signal) - }) - - it('aborts only the observer and keeps a later write failure sticky', async () => { - const onFailure = vi.fn() - const publication = new TransportPublicationDrain(() => {}, onFailure) - const settle = publication.trackWrite() - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort(new Error('observer cancelled')) - await expect(drain).rejects.toThrow('observer cancelled') - settle({ ok: false, error: new Error('lost write') }) - expect(onFailure).toHaveBeenCalledOnce() - await expect(publication.drain(new AbortController().signal)).rejects.toThrow('lost write') - expect(() => publication.assertCurrent()).toThrow('lost write') - }) - - it('fails once when the transport it was bound to is replaced', async () => { - let current = true - const onFailure = vi.fn() - const publication = new TransportPublicationDrain(() => { - if (!current) { - throw new Error('transport_replaced') - } - }, onFailure) - const settle = publication.trackWrite() - current = false - const drain = publication.drain(new AbortController().signal) - await expect(drain).rejects.toThrow('transport_replaced') - settle({ ok: true }) - expect(() => publication.assertDrained()).toThrow('transport_replaced') - expect(onFailure).toHaveBeenCalledOnce() - }) - - it('refuses construction against a transport that is already stale', () => { - expect( - () => - new TransportPublicationDrain(() => { - throw new Error('transport_replaced') - }) - ).toThrow('transport_replaced') - }) -}) diff --git a/src/shared/transport-publication-drain.ts b/src/shared/transport-publication-drain.ts deleted file mode 100644 index 3814b120195..00000000000 --- a/src/shared/transport-publication-drain.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { waitForPromiseWithSignal } from './abort-signal-reason' - -export type TransportPublicationSettlement = { ok: true } | { ok: false; error: Error } - -/** Local write completion only; downstream consumption requires separate proof. */ -export class TransportPublicationDrain { - private pending = 0 - protected failure: Error | undefined - private readonly observers = new Set<() => void>() - - constructor( - private readonly assertTransport: () => void, - private readonly onFailure: (error: Error) => void = () => {} - ) { - this.assertCurrent() - } - - trackWrite(): (result: TransportPublicationSettlement) => void { - this.pending++ - let settled = false - return (result) => { - if (settled) { - return - } - settled = true - this.pending-- - if (!result.ok) { - this.fail(result.error) - } - this.changed() - } - } - - fail(error: Error): void { - if (this.failure) { - return - } - this.failure = error - this.changed() - this.onFailure(error) - } - - assertDrained(): void { - this.assertCurrent() - if (this.pending !== 0) { - throw new Error('transport_publication_not_drained') - } - } - - async drain(signal: AbortSignal): Promise { - signal.throwIfAborted() - while (this.pending !== 0) { - this.assertCurrent() - // Why: no Promise.withResolvers — the legacy relay bundle still targets Node 18 hosts. - let notify!: () => void - const changed = new Promise((resolve) => { - notify = resolve - }) - this.observers.add(notify) - try { - await waitForPromiseWithSignal(changed, signal) - } finally { - this.observers.delete(notify) - } - } - signal.throwIfAborted() - this.assertDrained() - } - - assertCurrent(): void { - if (this.failure) { - throw this.failure - } - try { - this.assertTransport() - } catch (error) { - this.fail(error instanceof Error ? error : new Error(String(error))) - throw this.failure - } - } - - private changed(): void { - for (const notify of this.observers) { - notify() - } - } -} diff --git a/src/shared/workspace-session-terminal-buffers.test.ts b/src/shared/workspace-session-terminal-buffers.test.ts index 6c77d18271d..5eeaf5c9894 100644 --- a/src/shared/workspace-session-terminal-buffers.test.ts +++ b/src/shared/workspace-session-terminal-buffers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from './constants' +import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import type { WorkspaceSessionState } from './workspace-session-state-types' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from './terminal-scrollback-limits' import { getUtf8ByteLength } from './utf8-byte-limits' @@ -150,40 +150,6 @@ describe('pruneLocalTerminalScrollbackBuffers', () => { }) }) - it('keeps a dormant tab buffer, which has no live PTY to replay from', () => { - const session = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - 'local-repo::/w': [ - { - id: 'dormant-tab', - ptyId: null, - worktreeId: 'local-repo::/w', - title: 'Shell', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'dormant-tab': { - root: null, - activeLeafId: null, - expandedLeafId: null, - buffersByLeafId: { 'pane:1': 'dormant output' } - } - } - } - const result = pruneLocalTerminalScrollbackBuffers(session, [ - { id: 'local-repo', connectionId: null } - ]) - expect(result.terminalLayoutsByTabId['dormant-tab'].buffersByLeafId).toEqual({ - 'pane:1': 'dormant output' - }) - }) - it('drops scrollback for explicitly local execution hosts', () => { const result = pruneLocalTerminalScrollbackBuffers(makeRuntimeSession(), [ { diff --git a/src/shared/workspace-session-terminal-buffers.ts b/src/shared/workspace-session-terminal-buffers.ts index c7af0fc5b6e..fa4dd0e095b 100644 --- a/src/shared/workspace-session-terminal-buffers.ts +++ b/src/shared/workspace-session-terminal-buffers.ts @@ -91,24 +91,19 @@ export function pruneLocalTerminalScrollbackBuffers( repos: readonly RepoConnection[] ): WorkspaceSessionState { let repoById: Map | null = null - let tabById: Map | null = null + let worktreeIdByTabId: Map | null = null const tabsByWorktree = session.tabsByWorktree ?? {} const preservesScrollback = (tabId: string): boolean => { repoById ??= new Map(repos.map((repo) => [repo.id, repo] as const)) - if (!tabById) { - tabById = new Map() + if (!worktreeIdByTabId) { + worktreeIdByTabId = new Map() for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { for (const tab of tabs) { - tabById.set(tab.id, { worktreeId, ptyId: tab.ptyId }) + worktreeIdByTabId.set(tab.id, worktreeId) } } } - const tab = tabById.get(tabId) - // A dormant tab has no live PTY to replay from, so its saved buffer is the only copy. - return ( - tab?.ptyId === null || - shouldPreserveTerminalScrollbackBuffersForRepoMap(tab?.worktreeId, repoById) - ) + return shouldPreserveTerminalScrollbackBuffersForRepoMap(worktreeIdByTabId.get(tabId), repoById) } const terminalLayoutsByTabIdForRead = session.terminalLayoutsByTabId ?? {} From 5a56636f6679071d6ec68b851ef7932cd3222560 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:03:48 -0700 Subject: [PATCH 17/26] Bound E2E package setup and retain cancellation traces (#24617) * test: align source-control fixtures with current store contracts * Bound E2E package setup and retain cancelled-job traces --- .github/workflows/e2e.yml | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8dad06a1faf..517c77c9ddd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -176,7 +176,21 @@ jobs: # Native cache misses need the compiler, Electron needs Xvfb, and paired # Quick Open needs ripgrep. Install them in one apt transaction per shard. - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils + # The Azure archive took 16 minutes for one font package; bound setup + # separately so a slow mirror cannot consume the shard's test budget. + run: &install_e2e_tools | + for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + if [ -f "$source" ]; then + sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" + fi + done + sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF' + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + Acquire::Retries "1"; + APTCONF + timeout 120 sudo apt-get update + timeout 300 sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -241,7 +255,7 @@ jobs: # them as an artifact makes post-mortem debugging on CI possible without # re-running locally. - name: Upload Playwright traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-${{ matrix.shard_name }} @@ -267,7 +281,7 @@ jobs: # unbounded inventory fallback; the paired fixture exercises that real boundary. # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this # lane now receives those specs from pr.yml's SSH source mapping. - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies with: @@ -344,7 +358,7 @@ jobs: pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}" - name: Upload Playwright traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-changed @@ -403,7 +417,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies with: @@ -459,7 +473,7 @@ jobs: fi - name: Upload watcher isolation traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }} @@ -544,7 +558,7 @@ jobs: ORCA_E2E_FORWARD_APP_LOGS: '1' run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 - if: failure() + if: failure() || cancelled() with: name: localhost-ssh-traces path: test-results/ From a050afbe8668ac308c3b09cf368a8f6bafca238f Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:06:15 -0700 Subject: [PATCH 18/26] fix(terminal): stop parking pass queueing no-op renders that trip React #185 during worktree removal (#23636) * fix(terminal): stop parking pass queueing no-op renders during pane-close bursts Removing an active worktree with many terminal panes closed each pane in its own commit. Every commit re-ran the parking pass, whose functional setters queued a render even when the sets were unchanged, so each commit left work pending and React's nested-update counter climbed past 50 (#185), taking down the terminal workbench boundary. Dispatch only when a set actually changes. * fix: initialize parking state mirror lazily and verify transitions --------- Co-authored-by: m4air Co-authored-by: m4air --- .../use-terminal-parking-pass.test.tsx | 102 ++++++++++++++++++ .../components/use-terminal-parking-pass.ts | 39 ++++--- 2 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/use-terminal-parking-pass.test.tsx diff --git a/src/renderer/src/components/use-terminal-parking-pass.test.tsx b/src/renderer/src/components/use-terminal-parking-pass.test.tsx new file mode 100644 index 00000000000..d6ed94e73b3 --- /dev/null +++ b/src/renderer/src/components/use-terminal-parking-pass.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { StrictMode, useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' +import { useTerminalParkingPass } from './use-terminal-parking-pass' + +const parkingPass = vi.hoisted(() => ({ parkedIds: ['wt-parked'] })) + +vi.mock('./terminal-parking-pass-candidates', () => ({ + canOrdinarilyParkRetentionCandidate: () => true, + collectTerminalParkingPassCandidates: () => ({ + retentionCandidates: [], + // Fresh Set each pass, like the real selector. + nextParkedTerminalWorktreeIds: new Set(parkingPass.parkedIds), + nowMs: 0, + overrides: {}, + parkingTimers: new Map() + }) +})) +vi.mock('./terminal-pane/parked-terminal-buffer-capture', () => ({ + captureParkedTerminalBuffers: () => true +})) + +function useParkingPassHost() { + // Why own state: like the workbench's store subscriptions, an update on this fiber leaves lanes on + // its alternate, which defeats React's eager same-state bailout for the setters below. + const [revision, setRevision] = useState(0) + const [parked, setParked] = useState>(() => new Set()) + const [forceParked, setForceParked] = useState>(() => new Set()) + const [exempt, setExempt] = useState>(() => new Set()) + const controller = { + activeView: 'terminal', + activityTerminalPortals: [], + backgroundMountRevision: 0, + parkedCaptureDoneRef: { current: new Set() }, + pairedRuntimeParkingEnvironmentIds: [], + pendingStartupByTabId: {}, + renderedActiveWorktreeId: 'wt-active', + setEvictionExemptTerminalTabIds: setExempt, + setForceParkedTerminalWorktreeIds: setForceParked, + setParkedTerminalWorktreeIds: setParked, + setTerminalParkingRevision: () => {}, + tabsByWorktree: {}, + terminalParkingEnabled: true, + terminalParkingRevision: revision, + terminalProviderSnapshotCapabilityRevision: 0, + terminalRetentionBudgetEnabled: false, + terminalSshParkingEnabled: false, + workspaceSurfaceIds: [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only the fields stubbed above. + useTerminalParkingPass(controller as unknown as TerminalParkingFoundation) + return { parked, forceParked, exempt, bump: () => setRevision((r) => r + 1) } +} + +describe('useTerminalParkingPass', () => { + beforeEach(() => { + parkingPass.parkedIds = ['wt-parked'] + }) + + it('does not queue a render when a pass produces the same parking sets', () => { + let renders = 0 + const { result } = renderHook(() => { + renders += 1 + return useParkingPassHost() + }) + expect([...result.current.parked]).toEqual(['wt-parked']) + + // Why: during a worktree removal every pty-exit commit re-runs this pass; each no-op render it + // queued kept React's nested-update counter climbing until #185. + const settled = renders + for (let i = 0; i < 5; i++) { + act(() => result.current.bump()) + } + expect(renders - settled).toBe(5) + expect([...result.current.parked]).toEqual(['wt-parked']) + }) + + it('applies changed parking decisions after unchanged passes', () => { + const { result } = renderHook(() => useParkingPassHost()) + act(() => result.current.bump()) + parkingPass.parkedIds = ['wt-other'] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual(['wt-other']) + parkingPass.parkedIds = [] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual([]) + }) + + it('keeps parking transitions working under StrictMode', () => { + const { result } = renderHook(() => useParkingPassHost(), { wrapper: StrictMode }) + expect([...result.current.parked]).toEqual(['wt-parked']) + parkingPass.parkedIds = [] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual([]) + parkingPass.parkedIds = ['wt-parked'] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual(['wt-parked']) + }) +}) diff --git a/src/renderer/src/components/use-terminal-parking-pass.ts b/src/renderer/src/components/use-terminal-parking-pass.ts index 7dde35d68cb..3bb09d67d54 100644 --- a/src/renderer/src/components/use-terminal-parking-pass.ts +++ b/src/renderer/src/components/use-terminal-parking-pass.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useAppStore } from '../store' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, @@ -43,6 +43,12 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v terminalSshParkingEnabled, workspaceSurfaceIds } = controller + // Why: this effect is the sole writer, so these mirror the queued state without a render. + const dispatchedIdSetsRef = useRef<{ + parked: ReadonlySet + forceParked: ReadonlySet + evictionExempt: ReadonlySet + } | null>(null) useEffect(() => { const pass = collectTerminalParkingPassCandidates(controller) @@ -143,17 +149,26 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v } pass.nextParkedTerminalWorktreeIds.add(worktreeId) } - setParkedTerminalWorktreeIds((current) => - haveSameIdSet(current, pass.nextParkedTerminalWorktreeIds) - ? current - : pass.nextParkedTerminalWorktreeIds - ) - setForceParkedTerminalWorktreeIds((current) => - haveSameIdSet(current, forceParkedWorktreeIds) ? current : forceParkedWorktreeIds - ) - setEvictionExemptTerminalTabIds((current) => - haveSameIdSet(current, nextEvictionExemptTabIds) ? current : nextEvictionExemptTabIds - ) + // Why: a functional updater that returns `current` still queues a render. This pass runs in the + // synchronously flushed effects of every pty-exit commit, so those no-op renders chained a + // worktree removal's pane-close burst past React's nested-update limit (#185). + const dispatched = (dispatchedIdSetsRef.current ??= { + parked: new Set(), + forceParked: new Set(), + evictionExempt: new Set() + }) + if (!haveSameIdSet(dispatched.parked, pass.nextParkedTerminalWorktreeIds)) { + dispatched.parked = pass.nextParkedTerminalWorktreeIds + setParkedTerminalWorktreeIds(pass.nextParkedTerminalWorktreeIds) + } + if (!haveSameIdSet(dispatched.forceParked, forceParkedWorktreeIds)) { + dispatched.forceParked = forceParkedWorktreeIds + setForceParkedTerminalWorktreeIds(forceParkedWorktreeIds) + } + if (!haveSameIdSet(dispatched.evictionExempt, nextEvictionExemptTabIds)) { + dispatched.evictionExempt = nextEvictionExemptTabIds + setEvictionExemptTerminalTabIds(nextEvictionExemptTabIds) + } const retentionTtlEligibleIds = new Set( retentionBudgetCandidates .filter((candidate) => !candidate.ordinaryParkingCovers && !candidate.hasPendingSpawnWork) From 54be527fd15ed310de428f8b7a370adcbae33595 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:06:19 -0700 Subject: [PATCH 19/26] fix(markdown): cap rendered size of large Markdown previews to stop renderer freezes (#23634) * fix(markdown): cap rendered markdown size in preview tab and rich override The Open Preview tab rendered any file synchronously through react-markdown with no size check, and 'Open anyway' removed the rich-editor cap entirely. A 2 MB file blocked the renderer 7.8 s at 2.3 GB (5 MB: 38 s, 4 GB), matching scan29 crash reports where users killed a frozen Orca after opening a large .md. - Preview tab: over 600 KB shows a 'Render anyway' gate instead of rendering. - Both overrides stop at a 1 MiB hard cap; above it no render is offered. * fix(markdown): gate diff-tab markdown preview by size and localize gate copy The single-file diff Preview toggle sent the whole modified side to MarkdownPreview with only the 6 MB large-diff limit, so a multi-MB .md diff still froze the renderer. Wrap it in MarkdownPreviewSizeGate keyed by the diff tab id, and add the gate's strings to all locale catalogs. --------- Co-authored-by: m4air Co-authored-by: m4air --- ...orContent.markdown-classification.test.tsx | 1 + .../src/components/editor/EditorContent.tsx | 32 +-- .../editor/EditorDiffFileSurface.tsx | 31 ++- .../editor/EditorMarkdownFileSurface.tsx | 5 +- .../editor/MarkdownPreviewSizeGate.tsx | 58 +++++ .../editor/editor-panel-render-model.ts | 8 +- .../large-markdown-render-freeze.test.tsx | 230 ++++++++++++++++++ .../components/editor/markdown-rich-mode.ts | 4 +- .../editor/markdown-rich-size-limit.test.ts | 35 ++- .../editor/markdown-rich-size-limit.ts | 42 +++- src/renderer/src/i18n/locales/en.json | 5 + src/renderer/src/i18n/locales/es.json | 7 + src/renderer/src/i18n/locales/fr.json | 5 + src/renderer/src/i18n/locales/ja.json | 5 + src/renderer/src/i18n/locales/ko.json | 5 + src/renderer/src/i18n/locales/zh.json | 5 + 16 files changed, 440 insertions(+), 38 deletions(-) create mode 100644 src/renderer/src/components/editor/MarkdownPreviewSizeGate.tsx create mode 100644 src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx diff --git a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx index 23531fc95f0..919118208cd 100644 --- a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx @@ -62,6 +62,7 @@ vi.mock('./useEditorConflictNavigation', () => ({ vi.mock('@/store', () => { const state = { markdownRichModeSizeOverridden: false, + markdownRichModeSizeOverride: {}, setMarkdownRichModeSizeOverride: () => {}, reloadOpenCheckRunDetailsTab: () => {} } diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index c488bc1d25d..74e9e6d4086 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -8,6 +8,7 @@ import { EditorConflictReviewSurface } from './EditorConflictReviewSurface' import { EditorDiffFileSurface } from './EditorDiffFileSurface' import { EditorEditFileSurface } from './EditorEditFileSurface' import { EditorFileLoadErrorView } from './EditorFileLoadErrorView' +import { MarkdownPreviewSizeGate } from './MarkdownPreviewSizeGate' import type { FileContent } from './editor-panel-content-types' import { buildPdfScalePreferenceKey } from './pdf-scale-preference-storage' import { translate } from '@/i18n/i18n' @@ -208,22 +209,25 @@ export function EditorContent({ ) } const previewSourceFileId = activeFile.markdownPreviewSourceFileId ?? activeFile.filePath + const previewContent = editBuffers[previewSourceFileId] ?? fileContent.content return (
- + + +
) } diff --git a/src/renderer/src/components/editor/EditorDiffFileSurface.tsx b/src/renderer/src/components/editor/EditorDiffFileSurface.tsx index 1597e3cef27..1a9660d2316 100644 --- a/src/renderer/src/components/editor/EditorDiffFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorDiffFileSurface.tsx @@ -4,6 +4,7 @@ import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' import { getDiffContentSignature } from './diff-content-signature' import { DiffViewer, ImageDiffViewer, MarkdownPreview } from './editor-lazy-views' import { ExternalFileChangeBanner } from './ExternalFileChangeBanner' +import { MarkdownPreviewSizeGate } from './MarkdownPreviewSizeGate' import type { useMarkdownDocuments } from './useMarkdownDocuments' type MarkdownDocumentsController = ReturnType @@ -117,19 +118,25 @@ export function EditorDiffFileSurface({ )}
- + isDiff + > + +
) diff --git a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx index 4db7c9b5045..35a840cb284 100644 --- a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx @@ -6,6 +6,7 @@ import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' import { formatBytes } from '../status-bar/workspace-space-format' import { MarkdownPreview, RichMarkdownEditor } from './editor-lazy-views' import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter' +import { exceedsMarkdownRenderOverrideSizeLimit } from './markdown-rich-size-limit' import type { MarkdownRenderState } from './markdown-render-mode' import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary' import type { useMarkdownDocuments } from './useMarkdownDocuments' @@ -57,6 +58,8 @@ export function EditorMarkdownFileSurface({ if (renderMode === 'source' && mdViewMode === 'rich') { // Why: only a size fallback is recoverable — unsupported syntax would round-trip badly, so it gets no override. const isSizeFallback = richModeUnsupportedMessage === null + const canOverrideSize = + isSizeFallback && !exceedsMarkdownRenderOverrideSizeLimit(currentContent) const richFallbackMessage = richModeUnsupportedMessage ?? translate( @@ -68,7 +71,7 @@ export function EditorMarkdownFileSurface({
{richFallbackMessage} - {isSizeFallback ? ( + {canOverrideSize ? ( + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 97b4492750e..e7c01b40201 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -14,6 +14,7 @@ import type { FileContent } from './editor-panel-content-types' import { canUseChangesModeForFile } from './editor-panel-file-mode' import { getMarkdownRenderMode, type MarkdownRenderState } from './markdown-render-mode' import { getCachedMarkdownRichModeEligibility } from './markdown-rich-mode-eligibility-cache' +import { canRenderMarkdownAtSize } from './markdown-rich-size-limit' type StoreState = ReturnType @@ -163,7 +164,12 @@ export function getEditorPanelRenderModel({ ((activeFile.mode === 'markdown-preview' && fileContents[activeFile.id] !== undefined && fileContents[activeFile.id]?.isBinary !== true && - !fileContents[activeFile.id]?.loadError) || + !fileContents[activeFile.id]?.loadError && + canRenderMarkdownAtSize( + editorDrafts[activeFile.markdownPreviewSourceFileId ?? activeFile.filePath] ?? + fileContents[activeFile.id].content, + markdownRichModeSizeOverridden + )) || (activeFile.mode === 'edit' && fileContents[activeFile.id] !== undefined && !isChangesMode && diff --git a/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx b/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx new file mode 100644 index 00000000000..fa03f02fa00 --- /dev/null +++ b/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' + +const store = vi.hoisted(() => { + const initialOverride: Record = {} + const state = { + markdownRichModeSizeOverride: initialOverride, + setMarkdownRichModeSizeOverride: (fileId: string, enabled: boolean): void => { + state.markdownRichModeSizeOverride = { + ...state.markdownRichModeSizeOverride, + [fileId]: enabled + } + }, + reloadOpenCheckRunDetailsTab: () => {} + } + return state +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (storeState: typeof store) => unknown) => selector(store), { + getState: () => store + }) +})) + +vi.mock('./editor-lazy-views', () => { + const view = (name: string) => () =>
+ return { + MonacoEditor: view('source'), + CombinedDiffViewer: view('combined-diff'), + RichMarkdownEditor: view('rich-editor'), + MarkdownPreview: view('preview'), + DiffViewer: view('diff'), + ImageDiffViewer: view('image-diff') + } +}) + +vi.mock('./useMarkdownDocuments', () => ({ + useMarkdownDocuments: () => ({ + markdownDocuments: [], + onOpenDocLink: () => {}, + previewProps: { markdownDocuments: [], onOpenDocument: async () => {} }, + mdSave: async () => true + }) +})) + +vi.mock('./useEditorConflictNavigation', () => ({ + useEditorConflictNavigation: () => () => undefined +})) + +import { EditorContent } from './EditorContent' +import { EditorDiffFileSurface } from './EditorDiffFileSurface' +import type { useMarkdownDocuments } from './useMarkdownDocuments' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' +import { MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES } from './markdown-rich-size-limit' +import { getEditorPanelRenderModel } from './editor-panel-render-model' + +// Why: preview/rich surfaces build the whole document in one task; 2 MB blocked +// the renderer 7.8 s at 2.3 GB and 5 MB 38 s at 4 GB (scan29 crash reports). +const mediumDoc = 'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1024) +const hugeDoc = 'a'.repeat(MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) +// Multibyte text whose UTF-16 length is under the cap but UTF-8 size is over it. +const hugeCjkDoc = '中'.repeat(Math.ceil((MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) / 3)) + +const sourceFile: OpenFile = { + id: '/repo/BIG.md', + filePath: '/repo/BIG.md', + relativePath: 'BIG.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false +} +const previewTab: OpenFile = { + ...sourceFile, + id: `markdown-preview::${sourceFile.id}`, + mode: 'markdown-preview', + markdownPreviewSourceFileId: sourceFile.id +} + +function richRenderMode(content: string, sizeOverridden: boolean) { + return getEditorPanelRenderModel({ + activeFile: sourceFile, + fileContents: { [sourceFile.id]: { content, isBinary: false } }, + editorDrafts: {}, + gitStatusEntries: undefined, + gitBranchEntries: undefined, + markdownViewMode: { [sourceFile.id]: 'rich' }, + markdownRichModeSizeOverridden: sizeOverridden, + isChangesMode: false, + canOpenWorkspaceFileBrowser: true + }).inlineMarkdownRenderState?.renderMode +} + +function renderPreviewTab(content: string) { + const fileContents = { [previewTab.id]: { content, isBinary: false } } + const props = { + activeFile: previewTab, + viewStateScopeId: previewTab.id, + fileContents, + diffContents: {}, + editBuffers: {}, + openFiles: [sourceFile, previewTab], + worktreeEntries: [], + resolvedLanguage: 'markdown', + isMarkdown: true, + isMermaid: false, + isCsv: false, + isNotebook: false, + mdViewMode: 'rich' as const, + inlineMarkdownRenderState: null, + isChangesMode: false, + sideBySide: false, + pendingEditorReveal: null, + handleContentChange: vi.fn(), + handleContentChangeForFile: vi.fn(), + handleDirtyStateHint: vi.fn(), + handleSave: vi.fn(), + handleSaveForFile: vi.fn(), + reloadContent: vi.fn() + } + const view = render() + return { + view, + rerender: () => view.rerender(), + isPreviewRendered: () => view.container.querySelector('[data-editor-view="preview"]') !== null + } +} + +const diffTab: OpenFile = { + ...sourceFile, + id: 'diff::unstaged::/repo/BIG.md', + mode: 'diff', + diffSource: 'unstaged' +} + +const markdownDocumentsStub: ReturnType = { + markdownDocuments: [], + openMarkdownDocument: async () => {}, + onOpenDocLink: () => {}, + previewProps: { markdownDocuments: [], onOpenDocument: async () => {} }, + mdSave: async () => true +} + +function renderDiffPreview(content: string) { + const diffContent: GitDiffResult = { + kind: 'text', + originalContent: '', + modifiedContent: content, + originalIsBinary: false, + modifiedIsBinary: false + } + const props = { + activeFile: diffTab, + diffContent, + editBuffer: undefined, + resolvedLanguage: 'markdown', + sideBySide: false, + viewStateScopeId: diffTab.id, + diffViewStateKey: diffTab.id, + mdViewMode: 'preview' as const, + isMarkdown: true, + showMarkdownTableOfContents: false, + onCloseMarkdownTableOfContents: vi.fn(), + markdownAnnotationsEnabled: false, + markdownDocuments: markdownDocumentsStub, + onContentChange: vi.fn(), + onSave: vi.fn(), + reloadContent: vi.fn() + } + const view = render() + return { + rerender: () => view.rerender(), + isPreviewRendered: () => view.container.querySelector('[data-editor-view="preview"]') !== null + } +} + +afterEach(() => { + cleanup() + store.markdownRichModeSizeOverride = {} +}) + +describe('large markdown render guard', () => { + it('"Open anyway" still reaches the rich editor below the hard cap', () => { + expect(richRenderMode(mediumDoc, false)).toBe('source') + expect(richRenderMode(mediumDoc, true)).toBe('rich-editor') + }) + + it('"Open anyway" cannot push a document past the hard cap into the rich editor', () => { + expect(richRenderMode(hugeDoc, true)).toBe('source') + expect(richRenderMode(hugeCjkDoc, true)).toBe('source') + }) + + it('preview tab renders small documents directly', () => { + expect(renderPreviewTab('# Small').isPreviewRendered()).toBe(true) + }) + + it('preview tab gates documents over the preview limit behind "Render anyway"', () => { + const tab = renderPreviewTab(mediumDoc) + expect(tab.isPreviewRendered()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Render anyway' })) + tab.rerender() + expect(tab.isPreviewRendered()).toBe(true) + }) + + it('preview tab never renders documents over the hard cap', () => { + store.markdownRichModeSizeOverride = { [previewTab.id]: true } + const tab = renderPreviewTab(hugeDoc) + expect(tab.isPreviewRendered()).toBe(false) + expect(screen.queryByRole('button', { name: 'Render anyway' })).toBeNull() + }) + + it('diff preview toggle gates the modified side over the preview limit', () => { + expect(renderDiffPreview('# Small').isPreviewRendered()).toBe(true) + cleanup() + const diff = renderDiffPreview(mediumDoc) + expect(diff.isPreviewRendered()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Render anyway' })) + diff.rerender() + expect(diff.isPreviewRendered()).toBe(true) + }) + + it('diff preview toggle never renders a modified side over the hard cap', () => { + store.markdownRichModeSizeOverride = { [diffTab.id]: true } + expect(renderDiffPreview(hugeDoc).isPreviewRendered()).toBe(false) + expect(screen.queryByRole('button', { name: 'Render anyway' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index a499acd02dd..cced3ef52c1 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -2,7 +2,7 @@ import { defaultSchema } from 'rehype-sanitize' import { normalizeDetailsOpeningTag } from './details-markdown-html' import { getRichMarkdownRoundTripOutput } from './markdown-round-trip' import { extractFrontMatter } from './markdown-frontmatter' -import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' +import { canRenderMarkdownAtSize } from './markdown-rich-size-limit' import { translate } from '@/i18n/i18n' export type MarkdownRichModeUnsupportedReason = @@ -141,7 +141,7 @@ export function getMarkdownRichModeEligibilityDecision({ sizeOverridden: boolean }): MarkdownRichModeEligibilityDecision { return { - exceedsSizeLimit: !sizeOverridden && exceedsMarkdownRichModeSizeLimit(content), + exceedsSizeLimit: !canRenderMarkdownAtSize(content, sizeOverridden), unsupportedReason: getMarkdownRichModeUnsupportedReason(content) } } diff --git a/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts b/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts index 3539ffc565f..47a9447ce56 100644 --- a/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' -import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' +import { + MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES, + canRenderMarkdownAtSize, + exceedsMarkdownRenderOverrideSizeLimit, + exceedsMarkdownRichModeSizeLimit +} from './markdown-rich-size-limit' describe('exceedsMarkdownRichModeSizeLimit', () => { it('uses a 600 KB default rich-mode ceiling', () => { @@ -23,3 +28,31 @@ describe('exceedsMarkdownRichModeSizeLimit', () => { ).toBe(true) }) }) + +describe('exceedsMarkdownRenderOverrideSizeLimit', () => { + it('allows exactly the override limit and rejects one byte over', () => { + const limit = MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + expect(exceedsMarkdownRenderOverrideSizeLimit('a'.repeat(limit))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit('a'.repeat(limit + 1))).toBe(true) + }) + + it('counts UTF-8 bytes, not UTF-16 length', () => { + const limit = MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + // 3-byte CJK chars: floor(limit / 3) fit, one more does not. + expect(exceedsMarkdownRenderOverrideSizeLimit('中'.repeat(Math.floor(limit / 3)))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit('中'.repeat(Math.floor(limit / 3) + 1))).toBe( + true + ) + // Surrogate pairs are 2 UTF-16 units but 4 bytes. + expect(exceedsMarkdownRenderOverrideSizeLimit('😀'.repeat(limit / 4))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit(`${'😀'.repeat(limit / 4)}a`)).toBe(true) + }) + + it('lets the override lift only the rich limit, never the hard cap', () => { + const medium = 'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1) + const huge = 'a'.repeat(MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) + expect(canRenderMarkdownAtSize(medium, false)).toBe(false) + expect(canRenderMarkdownAtSize(medium, true)).toBe(true) + expect(canRenderMarkdownAtSize(huge, true)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-size-limit.ts b/src/renderer/src/components/editor/markdown-rich-size-limit.ts index b6ec31ee8fb..934d6e20d93 100644 --- a/src/renderer/src/components/editor/markdown-rich-size-limit.ts +++ b/src/renderer/src/components/editor/markdown-rich-size-limit.ts @@ -1,14 +1,42 @@ import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' -const richMarkdownSizeEncoder = new TextEncoder() -// Why: rich-mode eligibility is checked during render-model work, so this -// avoids allocating a large Uint8Array every time markdown content changes. -const richMarkdownSizeBuffer = new Uint8Array(RICH_MARKDOWN_MAX_SIZE_BYTES + 1) +// Why: "Open anyway"/"Render anyway" still build the whole document in one task; +// in Electron 43 on M-series a ~1 MB preview blocks 3.8 s at 1.3 GB and 2 MB +// blocks 7.8 s at 2.3 GB, so no override renders past this. +export const MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES = 1024 * 1024 -export function exceedsMarkdownRichModeSizeLimit(markdownContent: string): boolean { - const probe = richMarkdownSizeEncoder.encodeInto(markdownContent, richMarkdownSizeBuffer) +const markdownSizeEncoder = new TextEncoder() +// Why: size checks run during render-model work, so this avoids allocating a +// large Uint8Array every time markdown content changes. +const markdownSizeBuffer = new Uint8Array( + Math.max(RICH_MARKDOWN_MAX_SIZE_BYTES, MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES) + 1 +) +function exceedsUtf8ByteLimit(content: string, limit: number): boolean { + // Why: each UTF-16 unit encodes to 1-3 bytes, so most sizes resolve without encoding. + if (content.length > limit) { + return true + } + if (content.length * 3 <= limit) { + return false + } + const probe = markdownSizeEncoder.encodeInto(content, markdownSizeBuffer.subarray(0, limit + 1)) // Why: encodeInto() never writes partial UTF-8 sequences. A multibyte // character can leave written at the exact limit while unread content remains. - return probe.written > RICH_MARKDOWN_MAX_SIZE_BYTES || probe.read < markdownContent.length + return probe.written > limit || probe.read < content.length +} + +export function exceedsMarkdownRichModeSizeLimit(markdownContent: string): boolean { + return exceedsUtf8ByteLimit(markdownContent, RICH_MARKDOWN_MAX_SIZE_BYTES) +} + +export function exceedsMarkdownRenderOverrideSizeLimit(markdownContent: string): boolean { + return exceedsUtf8ByteLimit(markdownContent, MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES) +} + +/** Whether a rendered (rich or preview) markdown surface may build this document. */ +export function canRenderMarkdownAtSize(markdownContent: string, sizeOverridden: boolean): boolean { + return sizeOverridden + ? !exceedsMarkdownRenderOverrideSizeLimit(markdownContent) + : !exceedsMarkdownRichModeSizeLimit(markdownContent) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 21fcdf4beea..e2a1494d6eb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -119,6 +119,11 @@ "tooLarge": "File is larger than the {{limit}} rich editing limit. Showing source mode instead.", "openAnyway": "Open anyway" }, + "markdownPreview": { + "tooLarge": "File is larger than the {{limit}} preview limit. Open the file to view its source.", + "tooLargeInDiff": "File is larger than the {{limit}} preview limit. Switch to source mode to view the diff.", + "renderAnyway": "Render anyway" + }, "fileLoad": { "hostUnresolved": "The host couldn't find this file's workspace. It may have been removed, or the host may not know about it yet. Retry, or close this tab from the tab strip." } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c1dc27fd97d..45afe6f04a0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15543,5 +15543,12 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "editor": { + "markdownPreview": { + "tooLarge": "El archivo supera el límite de vista previa de {{limit}}. Abre el archivo para ver su código fuente.", + "tooLargeInDiff": "El archivo supera el límite de vista previa de {{limit}}. Cambia al modo fuente para ver las diferencias.", + "renderAnyway": "Renderizar de todos modos" + } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index cc9d5683689..376d83373bb 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18464,6 +18464,11 @@ }, "fileLoad": { "hostUnresolved": "L'hôte n'a pas pu trouver l'espace de travail de ce fichier. Il a peut-être été supprimé ou l'hôte n'en est peut-être pas encore au courant. Réessayez ou fermez cet onglet à partir de la bande d'onglets." + }, + "markdownPreview": { + "tooLarge": "Le fichier dépasse la limite d'aperçu de {{limit}}. Ouvrez le fichier pour afficher sa source.", + "tooLargeInDiff": "Le fichier dépasse la limite d'aperçu de {{limit}}. Passez en mode source pour afficher les différences.", + "renderAnyway": "Afficher quand même" } }, "checksPanel": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 485f45e6e6f..84cc645254d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18416,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "ホストはこのファイルのワークスペースを見つけることができませんでした。削除されたか、ホストがまだそれを認識していない可能性があります。再試行するか、タブストリップからこのタブを閉じてください。" + }, + "markdownPreview": { + "tooLarge": "ファイルがプレビュー上限の {{limit}} を超えています。ソースを表示するにはファイルを開いてください。", + "tooLargeInDiff": "ファイルがプレビュー上限の {{limit}} を超えています。差分を表示するにはソースモードに切り替えてください。", + "renderAnyway": "それでも表示" } }, "featureTips": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 23f07a9d2e9..1e340fdc837 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18416,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "호스트가 이 파일의 워크스페이스를 찾을 수 없습니다. 삭제되었거나 호스트가 아직 이에 대해 알지 못할 수도 있습니다. 다시 시도하거나 탭 표시줄에서 이 탭을 닫으세요." + }, + "markdownPreview": { + "tooLarge": "파일이 미리보기 한도 {{limit}}보다 큽니다. 소스를 보려면 파일을 여세요.", + "tooLargeInDiff": "파일이 미리보기 한도 {{limit}}보다 큽니다. 차이를 보려면 소스 모드로 전환하세요.", + "renderAnyway": "그래도 렌더링" } }, "featureTips": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6b13b751dd6..b0b9759f4fa 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18416,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "主机找不到该文件的工作区。它可能已被删除,或者主机可能还不知道。重试,或从选项卡栏中关闭此选项卡。" + }, + "markdownPreview": { + "tooLarge": "文件超过 {{limit}} 预览限制。请打开文件查看源代码。", + "tooLargeInDiff": "文件超过 {{limit}} 预览限制。请切换到源代码模式查看差异。", + "renderAnyway": "仍然渲染" } }, "featureTips": { From e3621295e67b4e19e97c13cf1754b32b07f7debe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:09:06 -0700 Subject: [PATCH 20/26] Remove empty passing sentinels from opt-in socket tests (#24621) * test: align source-control fixtures with current store contracts * Bound E2E package setup and retain cancelled-job traces * Remove empty passing sentinels from opt-in socket tests --- .../transport/cellular-handshake-stall-real-socket.test.ts | 7 ------- mobile/src/transport/rpc-client-live-recovery.test.ts | 7 ------- 2 files changed, 14 deletions(-) diff --git a/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts b/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts index 3cb87609c16..f5a1afda061 100644 --- a/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts +++ b/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts @@ -127,10 +127,3 @@ describe.runIf(RUN_LIVE)('issue #10119 — real socket, handshake slower than th expect(labels.slice(firstEscalated).every((l) => l !== 'Connecting…')).toBe(true) }, 60_000) }) - -// Why: vitest fails a file with zero tests; keep a sentinel for default runs. -describe.runIf(!RUN_LIVE)('real-socket handshake stall (skipped)', () => { - it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => { - expect(true).toBe(true) - }) -}) diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index bef276f918d..d2eea169c9f 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -199,10 +199,3 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => { } ) }) - -// Why: vitest fails a file with zero tests; keep a sentinel for default runs. -describe.runIf(!RUN_LIVE)('live foreground recovery (skipped)', () => { - it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => { - expect(true).toBe(true) - }) -}) From 76c79f473ad2eb71caa8f91e84119c0e373f751d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:13:27 -0700 Subject: [PATCH 21/26] fix(linux): move Chromium shared memory off a tiny /dev/shm (#23751) * fix(linux): move Chromium shared memory off a tiny /dev/shm Containers such as GitHub Codespaces mount a 64 MB /dev/shm by default. When it fills, Chromium aborts the renderer (IMMEDIATE_CRASH, SIGILL/SIGTRAP) on every reload, trapping Orca in a renderer crash loop. On Linux, stat /dev/shm before app ready and append --disable-dev-shm-usage when it is under 512 MB or unreadable (ORCA_DEV_SHM=off|force overrides). Records a dev_shm_policy crash breadcrumb so future container crashes are diagnosable. * docs(linux): correct dev-shm hasSwitch rationale --------- Co-authored-by: m4air Co-authored-by: m4air --- src/main/startup/linux-dev-shm-policy.test.ts | 159 ++++++++++++++++++ src/main/startup/linux-dev-shm-policy.ts | 54 ++++++ src/main/startup/main-process-preflight.ts | 2 + 3 files changed, 215 insertions(+) create mode 100644 src/main/startup/linux-dev-shm-policy.test.ts create mode 100644 src/main/startup/linux-dev-shm-policy.ts diff --git a/src/main/startup/linux-dev-shm-policy.test.ts b/src/main/startup/linux-dev-shm-policy.test.ts new file mode 100644 index 00000000000..e3c6a6de6a7 --- /dev/null +++ b/src/main/startup/linux-dev-shm-policy.test.ts @@ -0,0 +1,159 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { appMock, statfsSyncMock, breadcrumbMock } = vi.hoisted(() => ({ + appMock: { + commandLine: { + appendSwitch: vi.fn(), + hasSwitch: vi.fn(() => false) + } + }, + statfsSyncMock: vi.fn(), + breadcrumbMock: vi.fn() +})) + +vi.mock('electron', () => ({ app: appMock })) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + statfsSync: statfsSyncMock +})) +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + recordCrashBreadcrumb: breadcrumbMock +})) + +const MIB = 1024 * 1024 +const originalPlatform = process.platform +const originalOverride = process.env.ORCA_DEV_SHM + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function mockDevShm(totalMib: number, freeMib: number): void { + // statfs reports sizes in blocks; use 4 KiB blocks like tmpfs. + const bsize = 4096 + statfsSyncMock.mockReturnValue({ + bsize, + blocks: (totalMib * MIB) / bsize, + bavail: (freeMib * MIB) / bsize + }) +} + +beforeEach(() => { + vi.resetModules() + appMock.commandLine.appendSwitch.mockReset() + appMock.commandLine.hasSwitch.mockReset() + appMock.commandLine.hasSwitch.mockReturnValue(false) + statfsSyncMock.mockReset() + breadcrumbMock.mockReset() + delete process.env.ORCA_DEV_SHM +}) + +afterEach(() => { + setPlatform(originalPlatform) + if (originalOverride === undefined) { + delete process.env.ORCA_DEV_SHM + } else { + process.env.ORCA_DEV_SHM = originalOverride + } +}) + +describe('configureLinuxDevShmUsage', () => { + it('moves Chromium shared memory off a Docker-default 64 MB /dev/shm', async () => { + setPlatform('linux') + mockDevShm(64, 60) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(statfsSyncMock).toHaveBeenCalledWith('/dev/shm') + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmTotalMB: 64, + devShmFreeMB: 60, + devShmUsageDisabled: true, + reason: 'small' + }) + }) + + it('leaves a normally sized /dev/shm on the fast shared-memory path', async () => { + setPlatform('linux') + mockDevShm(8192, 8000) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmTotalMB: 8192, + devShmFreeMB: 8000, + devShmUsageDisabled: false, + reason: 'ok' + }) + }) + + it('disables /dev/shm usage when the mount is missing', async () => { + setPlatform('linux') + statfsSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmUsageDisabled: true, + reason: 'unreadable' + }) + }) + + it('honors ORCA_DEV_SHM=off on a small mount and =force on a large one', async () => { + setPlatform('linux') + mockDevShm(64, 60) + process.env.ORCA_DEV_SHM = 'off' + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + configureLinuxDevShmUsage() + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + + mockDevShm(8192, 8000) + process.env.ORCA_DEV_SHM = 'force' + configureLinuxDevShmUsage() + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + }) + + it('does not append the switch twice when it is already on the command line', async () => { + setPlatform('linux') + mockDevShm(64, 60) + appMock.commandLine.hasSwitch.mockReturnValue(true) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + }) + + it('is a no-op off Linux', async () => { + setPlatform('darwin') + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(statfsSyncMock).not.toHaveBeenCalled() + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + expect(breadcrumbMock).not.toHaveBeenCalled() + }) +}) + +describe('desktop startup wiring', () => { + it('runs the /dev/shm policy for every launch before app ready, GPU fallback included', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ).replace(/\r\n/g, '\n') + const call = source.indexOf('\n configureLinuxDevShmUsage()\n') + expect(call).toBeGreaterThan(-1) + expect(call).toBeLessThan(source.indexOf('\n maybeApplyGpuFallbackForThisLaunch()\n')) + }) +}) diff --git a/src/main/startup/linux-dev-shm-policy.ts b/src/main/startup/linux-dev-shm-policy.ts new file mode 100644 index 00000000000..8f707e3e64c --- /dev/null +++ b/src/main/startup/linux-dev-shm-policy.ts @@ -0,0 +1,54 @@ +import { statfsSync } from 'node:fs' +import { app } from 'electron' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' + +const DEV_SHM_PATH = '/dev/shm' +const DEV_SHM_OVERRIDE_ENV_VAR = 'ORCA_DEV_SHM' +// Why 512: Docker/Codespaces default to 64 MB, where Chromium's ring-buffer and +// texture allocations fail and IMMEDIATE_CRASH the renderer on every reload. +const MIN_DEV_SHM_TOTAL_MIB = 512 +const MIB = 1024 * 1024 + +type DevShmReading = { totalMib: number; freeMib: number } | null + +function readDevShm(): DevShmReading { + try { + const stats = statfsSync(DEV_SHM_PATH) + return { + totalMib: Math.floor((stats.blocks * stats.bsize) / MIB), + freeMib: Math.floor((stats.bavail * stats.bsize) / MIB) + } + } catch { + return null + } +} + +/** + * On Linux hosts with a tiny or missing /dev/shm (containers, Codespaces), + * back Chromium shared memory with /tmp files instead of letting renderers abort. + */ +export function configureLinuxDevShmUsage(): void { + if (process.platform !== 'linux') { + return + } + const override = (process.env[DEV_SHM_OVERRIDE_ENV_VAR] ?? '').trim().toLowerCase() + const reading = readDevShm() + const reason = + override === 'off' || override === 'force' + ? override + : reading === null + ? 'unreadable' + : reading.totalMib < MIN_DEV_SHM_TOTAL_MIB + ? 'small' + : 'ok' + const disable = reason === 'force' || reason === 'small' || reason === 'unreadable' + // Why hasSwitch: user argv may already carry it (headless serve appends its own later). + if (disable && !app.commandLine.hasSwitch('disable-dev-shm-usage')) { + app.commandLine.appendSwitch('disable-dev-shm-usage') + } + recordCrashBreadcrumb('dev_shm_policy', { + ...(reading ? { devShmTotalMB: reading.totalMib, devShmFreeMB: reading.freeMib } : {}), + devShmUsageDisabled: disable, + reason + }) +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index be0254a9d43..67be830901f 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -33,6 +33,7 @@ import { } from '../updater' import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity' import { enableRendererHeapHeadroom } from './renderer-heap-headroom' +import { configureLinuxDevShmUsage } from './linux-dev-shm-policy' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' import { startEventLoopStallProbe } from './event-loop-stall-probe' import { @@ -364,6 +365,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b optOutOfHiddenPageWakeUpThrottling() configureElectronNetworkCompatibility() enableRendererHeapHeadroom() + configureLinuxDevShmUsage() maybeApplyGpuFallbackForThisLaunch() if (!state.gpuFallbackActiveThisLaunch) { enableMainProcessGpuFeatures() From f20836c2962fd98350736552fa1f89c9346c2950 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:20:03 -0700 Subject: [PATCH 22/26] fix(recovery): prompt instead of reloading into a repeat Windows OOM when commit is exhausted (#23886) * fix(recovery): ask instead of reloading into a repeat Windows OOM with exhausted commit When another program exhausts Windows commit (RAM + page file), the renderer OOMs, Orca auto-reloads 250 ms later, and the new renderer OOMs again within seconds (launch 13084: 3.5 s after the reload; launch 22912: 34 s). The crash-loop breaker (3 in 60 s) never opens for this cadence, so the user is never told the machine is out of memory. Keep the first automatic reload, but when a win32 reason=oom death follows another OOM within 5 minutes and the pre-gone host sample shows under 512 MB of available commit, escalate to the existing recovery prompt with a new 'low-commit' cause that names the MB left and suggests closing apps or growing the page file. Records renderer_recovery_low_commit_prompt. No-op on macOS/Linux and when commit is healthy. * fix(recovery): gate low-commit prompt on post-OOM readings and recovered deaths only - Reject pre-gone samples taken at or before the previous OOM; they miss the commit that corpse released. - Record an OOM for the repeat window only once recovery actually runs, so skipped teardown OOMs cannot suppress the next first reload. - Skip the install-ACL diagnosis on the low-commit prompt, whose text would not explain Copy Commands. * fix(recovery): read commit at gone time when no sampler tick followed the previous OOM The 10 s pre-gone sampler lands between ~3.5 s repeat OOMs only ~35% of the time, so the gate usually fell back to a silent reload. A gone-time read can only over-report free commit (the corpse already released its pages), so it can miss a prompt but never raise a false one. * fix: reject invalid low-commit readings and clarify recovery advice --------- Co-authored-by: m4air Co-authored-by: m4air --- .../low-commit-oom-recovery-gate.test.ts | 151 ++++++++++++++ .../low-commit-oom-recovery-gate.ts | 94 +++++++++ src/main/startup/main-window-actions.ts | 4 +- src/main/startup/main-window-controller.ts | 18 +- ...MainWindow-low-commit-oom-recovery.test.ts | 186 ++++++++++++++++++ ...MainWindow-renderer-crash-recovery.test.ts | 38 +--- .../window/createMainWindow-test-harness.ts | 43 ++++ src/main/window/main-window-contracts.ts | 3 + .../window/main-window-focus-lifecycle.ts | 18 ++ .../window/renderer-recovery-prompt.test.ts | 28 +++ src/main/window/renderer-recovery-prompt.ts | 69 ++++--- .../renderer-recovery-reload-watchdog.ts | 16 +- src/renderer/src/i18n/locales/en.json | 5 +- 13 files changed, 600 insertions(+), 73 deletions(-) create mode 100644 src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts create mode 100644 src/main/crash-reporting/low-commit-oom-recovery-gate.ts create mode 100644 src/main/window/createMainWindow-low-commit-oom-recovery.test.ts diff --git a/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts b/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts new file mode 100644 index 00000000000..f3fe3c3904e --- /dev/null +++ b/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest' +import { withPlatform } from '../window/createMainWindow-test-harness' +import { + createLowCommitOomRecoveryGate, + LOW_COMMIT_REPEAT_OOM_WINDOW_MS +} from './low-commit-oom-recovery-gate' + +const OOM: Electron.RenderProcessGoneDetails = { reason: 'oom', exitCode: -536870904 } +const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 } +// Launch 22912 (Scan-30 1790622432/1790622459): OOM at 19:06:54.6, reload OOMed again at 19:07:28.7. +const FIRST_OOM = Date.parse('2026-09-28T19:06:54.600Z') +const RELOAD_OOM = Date.parse('2026-09-28T19:07:28.700Z') + +function sample(swapFreeMB: number | undefined, ageMs = 4_000) { + return () => ({ + systemMemoryPreGoneSampleAgeMs: ageMs, + ...(swapFreeMB === undefined ? {} : { systemMemoryPreGoneSwapFreeMB: swapFreeMB }) + }) +} + +// A gone-time read that resolved no commit field, as off-Electron. +const NO_GONE_TIME_READING = () => ({}) + +function goneTime(swapFreeMB: number) { + return () => ({ systemMemorySwapFreeMB: swapFreeMB }) +} + +function observeTwice( + read: ReturnType, + second = OOM, + gapMs = RELOAD_OOM - FIRST_OOM, + platform: NodeJS.Platform = 'win32', + readGoneTime: () => Record = NO_GONE_TIME_READING +) { + return withPlatform(platform, () => { + const gate = createLowCommitOomRecoveryGate(read, readGoneTime) + const first = gate.assess(OOM, FIRST_OOM) + gate.recordRecoveredDeath(OOM, FIRST_OOM) + return [first, gate.assess(second, FIRST_OOM + gapMs)] + }) +} + +describe('createLowCommitOomRecoveryGate', () => { + it('holds the reload of a repeat OOM with 60 MB of commit left', () => { + expect(observeTwice(sample(60))).toEqual([ + null, + { availableCommitMB: 60, sincePreviousOomMs: 34_100, commitReading: 'pre-gone' } + ]) + }) + + it('always lets the first OOM of the launch auto-reload, even with 5 MB left', () => { + expect(observeTwice(sample(5))[0]).toBeNull() + }) + + it.each([ + ['commit is healthy (744 MB)', sample(744), OOM, 34_100, 'win32'], + [ + 'the previous OOM was over 5 minutes ago', + sample(60), + OOM, + LOW_COMMIT_REPEAT_OOM_WINDOW_MS + 1, + 'win32' + ], + ['the death is not an OOM', sample(60), CRASHED, 34_100, 'win32'], + ['no commit reading exists', sample(undefined), OOM, 34_100, 'win32'], + ['the reading is stale', sample(60, 31_000), OOM, 34_100, 'win32'], + ['the host is macOS', sample(60), OOM, 34_100, 'darwin'], + ['the host is Linux', sample(60), OOM, 34_100, 'linux'] + ] as const)('reloads when %s', (_label, read, second, gapMs, platform) => { + expect(observeTwice(read, second, gapMs, platform)[1]).toBeNull() + }) + + // Launch 13084 (Scan-31): 12:20:37.207 then 12:22:59.975; each OOM restarts the window. + it('measures the window from the most recent OOM', () => { + const verdicts = withPlatform('win32', () => { + const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING) + return [ + '2026-09-29T12:06:19.869Z', + '2026-09-29T12:20:37.207Z', + '2026-09-29T12:20:40.665Z', + '2026-09-29T12:22:59.975Z' + ].map((iso) => { + const verdict = gate.assess(OOM, Date.parse(iso)) + gate.recordRecoveredDeath(OOM, Date.parse(iso)) + return verdict + }) + }) + expect(verdicts.map((v) => v?.sincePreviousOomMs ?? null)).toEqual([null, null, 3_458, 139_310]) + }) + + // Launch 13084: the repeat OOM came 3.458 s after the previous one, inside one 10 s sampler tick. + describe('when no sampler tick landed since the previous OOM', () => { + const gapMs = 3_458 + + it.each([ + ['taken before the previous OOM', 5_000], + ['taken exactly at the previous OOM', gapMs], + ['stale', 31_000] + ])('reads commit at gone time instead of trusting a reading %s', (_label, ageMs) => { + expect(observeTwice(sample(744, ageMs), OOM, gapMs, 'win32', goneTime(60))[1]).toEqual({ + availableCommitMB: 60, + sincePreviousOomMs: gapMs, + commitReading: 'gone-time' + }) + }) + + it('reloads when the gone-time reading shows commit recovered (2029 MB)', () => { + expect(observeTwice(sample(60, 5_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toBeNull() + }) + + it('prefers a reading taken after the previous OOM over the gone-time one', () => { + expect(observeTwice(sample(60, 1_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toEqual({ + availableCommitMB: 60, + sincePreviousOomMs: gapMs, + commitReading: 'pre-gone' + }) + }) + + it('reads nothing on macOS or Linux', () => { + const readGoneTime = vi.fn(goneTime(60)) + for (const platform of ['darwin', 'linux'] as const) { + expect(observeTwice(sample(60, 5_000), OOM, gapMs, platform, readGoneTime)[1]).toBeNull() + } + expect(readGoneTime).not.toHaveBeenCalled() + }) + }) + + it.each([Number.NaN, Infinity, -1])( + 'does not block recovery on an invalid commit reading (%s)', + (commitMB) => { + expect(observeTwice(sample(commitMB), OOM, 34_100, 'win32', goneTime(commitMB))[1]).toBeNull() + } + ) + + it.each([Number.NaN, Infinity, -1])('ignores an invalid sample age (%s)', (ageMs) => { + expect(observeTwice(sample(60, ageMs), OOM, 34_100, 'win32', goneTime(2_029))[1]).toBeNull() + }) + + it.each([0, -1])('does not block recovery when the clock fails to advance (%s)', (gapMs) => { + expect(observeTwice(sample(60), OOM, gapMs, 'win32', goneTime(60))[1]).toBeNull() + }) + + it('does not start the repeat window for an OOM that was never recovered', () => { + const verdict = withPlatform('win32', () => { + const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING) + gate.assess(OOM, FIRST_OOM) + return gate.assess(OOM, RELOAD_OOM) + }) + expect(verdict).toBeNull() + }) +}) diff --git a/src/main/crash-reporting/low-commit-oom-recovery-gate.ts b/src/main/crash-reporting/low-commit-oom-recovery-gate.ts new file mode 100644 index 00000000000..f9362822745 --- /dev/null +++ b/src/main/crash-reporting/low-commit-oom-recovery-gate.ts @@ -0,0 +1,94 @@ +import type { CrashReportDetailValue } from '../../shared/crash-reporting' +import { preGoneSystemMemoryDetails } from './pre-gone-host-memory' +import { getSystemMemoryDetails, SYSTEM_MEMORY_KEY_PREFIX } from './system-memory-details' + +// Why: when Windows commit is exhausted by another program, a recovery reload OOMs again within seconds +// (launch 13084: 3.5 s after the reload; launch 22912: 34 s), so a repeat OOM on a starved host asks the user instead. +export const LOW_COMMIT_REPEAT_OOM_WINDOW_MS = 5 * 60_000 +export const LOW_COMMIT_AVAILABLE_MB_THRESHOLD = 512 +// Two missed 10 s sampler ticks: an older reading may predate the squeeze or its relief. +const LOW_COMMIT_MAX_SAMPLE_AGE_MS = 30_000 + +export type LowCommitOomVerdict = { + /** Pre-gone MEMORYSTATUSEX.ullAvailPageFile, i.e. commit still available. */ + availableCommitMB: number + sincePreviousOomMs: number + /** 'gone-time' when no sampler tick landed since the previous OOM and the gate read commit itself. */ + commitReading: 'pre-gone' | 'gone-time' +} + +export type LowCommitOomRecoveryGate = { + /** Read at gone time; returns a verdict only when auto-reload would run straight back into the OOM. */ + assess: (details: Electron.RenderProcessGoneDetails, now: number) => LowCommitOomVerdict | null + /** Call only once the death is actually recovered, so a skipped teardown OOM cannot start the repeat window. */ + recordRecoveredDeath: (details: Electron.RenderProcessGoneDetails, goneAt: number) => void +} + +type MemoryDetails = Record + +function usablePreGoneCommitMB(sample: MemoryDetails, sincePreviousOomMs: number): number | null { + const availableCommitMB = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapFreeMB`] + const sampleAgeMs = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`] + if ( + typeof availableCommitMB !== 'number' || + typeof sampleAgeMs !== 'number' || + !Number.isFinite(availableCommitMB) || + availableCommitMB < 0 || + !Number.isFinite(sampleAgeMs) || + sampleAgeMs < 0 || + sampleAgeMs > LOW_COMMIT_MAX_SAMPLE_AGE_MS || + // Why: a reading from before the previous OOM misses the commit that corpse released. + sampleAgeMs >= sincePreviousOomMs + ) { + return null + } + return availableCommitMB +} + +export function createLowCommitOomRecoveryGate( + readPreGoneDetails: (now: number) => MemoryDetails = preGoneSystemMemoryDetails, + readGoneTimeDetails: () => MemoryDetails = getSystemMemoryDetails +): LowCommitOomRecoveryGate { + let previousOomAt: number | null = null + return { + assess: (details, now) => { + const previous = previousOomAt + if ( + // Only win32 swapFree is available commit; elsewhere it is not a verdict. + process.platform !== 'win32' || + details.reason !== 'oom' || + previous === null || + !Number.isFinite(now) || + now <= previous || + now - previous > LOW_COMMIT_REPEAT_OOM_WINDOW_MS + ) { + return null + } + const sincePreviousOomMs = now - previous + const preGoneMB = usablePreGoneCommitMB(readPreGoneDetails(now), sincePreviousOomMs) + // Why fall back: a 10 s sampler misses most ~3.5 s repeat loops. A gone-time read sees commit the corpse + // already released, so it can only over-report and miss a prompt, never raise a false one. + const goneTimeMB = + preGoneMB === null ? readGoneTimeDetails()[`${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB`] : null + const availableCommitMB = preGoneMB ?? goneTimeMB + if ( + typeof availableCommitMB !== 'number' || + !Number.isFinite(availableCommitMB) || + availableCommitMB < 0 || + availableCommitMB >= LOW_COMMIT_AVAILABLE_MB_THRESHOLD + ) { + return null + } + return { + availableCommitMB, + sincePreviousOomMs, + commitReading: preGoneMB === null ? 'gone-time' : 'pre-gone' + } + }, + recordRecoveredDeath: (details, goneAt) => { + if (details.reason === 'oom') { + previousOomAt = goneAt + } + } + } +} diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index 0acc8d1a074..30cdfc9c477 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -153,11 +153,13 @@ export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { export async function showRendererRecoveryPrompt( recentRecoveryCount: number, failure?: RendererRecoveryPromptFailure, - retry?: () => void + retry?: () => void, + availableCommitMB?: number ): Promise { await presentRendererRecoveryPrompt({ recentRecoveryCount, ...(failure ? { failure } : {}), + ...(availableCommitMB === undefined ? {} : { availableCommitMB }), isQuitting: () => state.isQuitting, diagnose: describeInstallDirAclPoison, showMessageBox: (options) => { diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index e2741b5d005..70f06235019 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -114,19 +114,27 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} reason: details.reason, expectedTeardown: getExpectedTeardownScope(webContentsId, false) }), - onRendererRecoveryExhausted: ({ details, recentRecoveryCount, cause, retry }) => { - // Why two names: a stalled reload never opened the breaker, and a bundle that says it did misreads the failure. + onRendererRecoveryExhausted: ({ details, recentRecoveryCount, cause, lowCommit, retry }) => { + // Why distinct names: a stalled reload or a low-commit hold never opened the breaker, and a bundle that says it did misreads the failure. recordDurableCrashBreadcrumb( cause === 'reload-stalled' ? 'renderer_recovery_reload_exhausted' - : 'renderer_recovery_circuit_breaker_open', + : cause === 'low-commit' + ? 'renderer_recovery_low_commit_prompt' + : 'renderer_recovery_circuit_breaker_open', { reason: details.reason, exitCode: details.exitCode ?? null, - recentRecoveryCount + recentRecoveryCount, + ...lowCommit } ) - void showRendererRecoveryPrompt(recentRecoveryCount, cause, retry) + void showRendererRecoveryPrompt( + recentRecoveryCount, + cause, + retry, + lowCommit?.availableCommitMB + ) }, deferLoad: true, ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), diff --git a/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts b/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts new file mode 100644 index 00000000000..218f48c5606 --- /dev/null +++ b/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', async () => + (await import('./createMainWindow-test-harness')).electronModuleMock() +) +vi.mock('@electron-toolkit/utils', async () => + (await import('./createMainWindow-test-harness')).electronToolkitUtilsMock() +) +vi.mock('./macos-tahoe-release', async () => + (await import('./createMainWindow-test-harness')).macosTahoeReleaseMock() +) +vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock()) +vi.mock('../browser/browser-manager', async () => + (await import('./createMainWindow-test-harness')).browserManagerMock() +) +vi.mock('../browser/browser-client-page-renderer-runtime', async () => { + const harness = await import('./createMainWindow-test-harness') + return { + attachBrowserClientPageRenderer: harness.attachClientPageRendererMock, + retireBrowserClientPageRenderer: harness.retireClientPageRendererMock + } +}) + +import { createMainWindow } from './createMainWindow' +import { resetExpectedTeardownStateForTest } from '../crash-reporting/expected-teardown-state' +import { + resetPreGoneSystemMemorySamplingForTest, + samplePreGoneSystemMemory +} from '../crash-reporting/pre-gone-host-memory' +import { setSystemMemoryInfoReaderForTest } from '../crash-reporting/system-memory-details' +import { setSwapVolumeFreeSpaceReaderForTest } from '../crash-reporting/swap-volume-free-space' +import { + createRendererRecoveryWindowHarness, + resetMainWindowMocks, + withPlatform +} from './createMainWindow-test-harness' + +// Launch 13084 (Scan-31 1790683596/1790684449/1790684587): the second reload OOMed 3.458 s after the first. +const OOM_AT = [ + '2026-09-29T12:06:19.869Z', + '2026-09-29T12:20:37.207Z', + '2026-09-29T12:20:40.665Z', + '2026-09-29T12:22:59.975Z' +].map((iso) => Date.parse(iso)) +// Commit at the two OOMs of launch 22912 (Scan-30 1790622432/1790622459): 744 MB, then 60 MB of a 130 GB limit. +const HEALTHY_FIRST_OOM_COMMIT_MB = 744 +const EXHAUSTED_COMMIT_MB = 60 +const RECOVERED_COMMIT_MB = 2_029 + +const OOM: Electron.RenderProcessGoneDetails = { reason: 'oom', exitCode: -536870904 } + +function hostWithAvailableCommit(swapFreeMB: number): void { + setSystemMemoryInfoReaderForTest(() => ({ + total: 32_000 * 1024, + free: 976 * 1024, + swapTotal: 130_000 * 1024, + swapFree: swapFreeMB * 1024 + })) + void samplePreGoneSystemMemory(Date.now()) +} + +async function runOomSequence( + platform: NodeJS.Platform, + commitAtEachOomMB: readonly (number | null)[], + goneTimeCommitMB?: number +): Promise<{ reloads: number; onRendererRecoveryExhausted: ReturnType }> { + const onRendererRecoveryExhausted = vi.fn() + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + createMainWindow(null, { onRendererRecoveryExhausted }) + for (const [index, goneAt] of OOM_AT.entries()) { + const commitMB = commitAtEachOomMB[index] + // null: no sampler tick since the previous OOM. + if (commitMB !== null) { + vi.setSystemTime(goneAt - 2_000) + withPlatform(platform, () => hostWithAvailableCommit(commitMB)) + } + vi.setSystemTime(goneAt) + if (goneTimeCommitMB !== undefined) { + // Only the host changes; no sampler tick commits it. + setSystemMemoryInfoReaderForTest(() => ({ swapFree: goneTimeCommitMB * 1024 })) + } + withPlatform(platform, () => windowHandlers['render-process-gone']?.({}, OOM)) + await vi.advanceTimersByTimeAsync(250) + } + // Minus the initial load. + return { + reloads: browserWindowInstance.loadFile.mock.calls.length - 1, + onRendererRecoveryExhausted + } +} + +describe('Windows renderer OOM recovery under exhausted commit', () => { + beforeEach(() => { + resetMainWindowMocks() + resetExpectedTeardownStateForTest() + resetPreGoneSystemMemorySamplingForTest() + setSwapVolumeFreeSpaceReaderForTest(async () => undefined) + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + setSystemMemoryInfoReaderForTest(null) + setSwapVolumeFreeSpaceReaderForTest(null) + resetPreGoneSystemMemorySamplingForTest() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('stops reloading into a repeat OOM and tells the user how much commit is left', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB + ]) + // The first OOM and the one 14 min later each still get their automatic reload. + expect(reloads).toBe(2) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + details: OOM, + cause: 'low-commit', + lowCommit: { + availableCommitMB: EXHAUSTED_COMMIT_MB, + sincePreviousOomMs: 3_458, + commitReading: 'pre-gone' + } + }) + ) + }) + + // The common field case: the 10 s sampler has no tick in the 3.458 s between the two OOMs. + it('reads commit at gone time when no sampler tick landed since the previous OOM', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + null, + EXHAUSTED_COMMIT_MB + ]) + expect(reloads).toBe(2) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + cause: 'low-commit', + lowCommit: { + availableCommitMB: EXHAUSTED_COMMIT_MB, + sincePreviousOomMs: 3_458, + commitReading: 'gone-time' + } + }) + ) + }) + + it('keeps reloading when the gone-time read shows commit recovered and no tick landed', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence( + 'win32', + [HEALTHY_FIRST_OOM_COMMIT_MB, EXHAUSTED_COMMIT_MB, null, null], + RECOVERED_COMMIT_MB + ) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('keeps auto-reloading repeat OOMs once commit has recovered', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + RECOVERED_COMMIT_MB, + RECOVERED_COMMIT_MB, + RECOVERED_COMMIT_MB + ]) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it.each(['darwin', 'linux'] as const)('is a no-op on %s', async (platform) => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence(platform, [ + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB + ]) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts index b3fc210be2c..b9f425f027b 100644 --- a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts +++ b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts @@ -31,6 +31,7 @@ import { import { attachClientPageRendererMock, browserWindowMock, + createRendererRecoveryWindowHarness, resetMainWindowMocks, retireClientPageRendererMock, withPlatform @@ -349,43 +350,6 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) - const createRendererRecoveryWindowHarness = () => { - const windowHandlers: Record void> = {} - const webContents = { - id: 143, - getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), - isDestroyed: vi.fn(() => false), - on: vi.fn((event, handler) => { - windowHandlers[event] = handler - }), - setZoomLevel: vi.fn(), - setBackgroundThrottling: vi.fn(), - invalidate: vi.fn(), - setWindowOpenHandler: vi.fn(), - send: vi.fn() - } - const browserWindowInstance = { - webContents, - on: vi.fn((event, handler) => { - windowHandlers[event] = handler - }), - isDestroyed: vi.fn(() => false), - isMaximized: vi.fn(() => true), - isFullScreen: vi.fn(() => false), - getSize: vi.fn(() => [1200, 800]), - setSize: vi.fn(), - maximize: vi.fn(), - show: vi.fn(), - loadFile: vi.fn(() => Promise.resolve()), - loadURL: vi.fn(() => Promise.resolve()) - } - browserWindowMock.mockImplementation(function () { - return browserWindowInstance - }) - - return { browserWindowInstance, windowHandlers } - } - it('reloads the app shell after an unexpected renderer process loss', () => { vi.useFakeTimers() diff --git a/src/main/window/createMainWindow-test-harness.ts b/src/main/window/createMainWindow-test-harness.ts index 6f7ae25da67..abf2dd8c829 100644 --- a/src/main/window/createMainWindow-test-harness.ts +++ b/src/main/window/createMainWindow-test-harness.ts @@ -146,3 +146,46 @@ export function withPlatform(platform: NodeJS.Platform, run: () => T): T { Object.defineProperty(process, 'platform', { configurable: true, value: original }) } } + +export type RendererRecoveryWindowHarness = { + browserWindowInstance: { loadFile: Mock<() => Promise>; loadURL: Mock<() => Promise> } + windowHandlers: Record void> +} + +/** A main window whose webContents events land in `windowHandlers` for suites that drive renderer recovery. */ +export function createRendererRecoveryWindowHarness(): RendererRecoveryWindowHarness { + const windowHandlers: RendererRecoveryWindowHarness['windowHandlers'] = {} + const webContents = { + id: 143, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(() => Promise.resolve()), + loadURL: vi.fn(() => Promise.resolve()) + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + return { browserWindowInstance, windowHandlers } +} diff --git a/src/main/window/main-window-contracts.ts b/src/main/window/main-window-contracts.ts index 5135be0fbe6..2b2e8282304 100644 --- a/src/main/window/main-window-contracts.ts +++ b/src/main/window/main-window-contracts.ts @@ -1,4 +1,5 @@ import type { KeybindingOverrides } from '../../shared/keybindings' +import type { LowCommitOomVerdict } from '../crash-reporting/low-commit-oom-recovery-gate' import type { RecoveryExhaustionCause, RecoveryReloadMilestone, @@ -31,6 +32,8 @@ export type CreateMainWindowOptions = { webContentsId: number recentRecoveryCount: number cause?: RecoveryExhaustionCause + /** Set with cause 'low-commit': the Windows commit reading that stopped the auto-reload. */ + lowCommit?: LowCommitOomVerdict /** Watched manual retry for the recovery prompt; an unwatched one cannot re-raise the prompt when it stalls too. */ retry?: () => void }) => void diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts index d494992e692..44e8924cc1d 100644 --- a/src/main/window/main-window-focus-lifecycle.ts +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -9,6 +9,7 @@ import { DEFAULT_RENDERER_RECOVERY_WINDOW_MS, RendererRecoveryCircuitBreaker } from '../crash-reporting/renderer-recovery-circuit-breaker' +import { createLowCommitOomRecoveryGate } from '../crash-reporting/low-commit-oom-recovery-gate' import { buildEditableContextMenuTemplate, matchingRichMarkdownContextMenuTableTarget, @@ -159,6 +160,7 @@ export function installMainWindowFocusLifecycle(args: { windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS, maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES }) + const lowCommitOomGate = createLowCommitOomRecoveryGate() const clearRendererRecoveryTimer = (): void => { if (rendererRecoveryTimer) { clearTimeout(rendererRecoveryTimer) @@ -187,6 +189,9 @@ export function installMainWindowFocusLifecycle(args: { ) { return } + const goneAt = Date.now() + // Why read at gone time: the sampler's next tick would see commit the corpse just released. + const lowCommit = lowCommitOomGate.assess(details, goneAt) rendererRecoveryTimer = setTimeout(() => { rendererRecoveryTimer = null if ( @@ -197,6 +202,19 @@ export function installMainWindowFocusLifecycle(args: { ) { return } + lowCommitOomGate.recordRecoveredDeath(details, goneAt) + if (lowCommit) { + // Why: a reload would OOM again on the starved host; only the user can free commit. + recoveryReloadWatchdog.escalate( + { + details, + recentRecoveryCount: rendererRecoveryCircuitBreaker.recentRecoveryCount(Date.now()) + }, + 'low-commit', + lowCommit + ) + return + } const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now()) if (!recovery.allowed) { // Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt. diff --git a/src/main/window/renderer-recovery-prompt.test.ts b/src/main/window/renderer-recovery-prompt.test.ts index a26700720eb..32f6e927b56 100644 --- a/src/main/window/renderer-recovery-prompt.test.ts +++ b/src/main/window/renderer-recovery-prompt.test.ts @@ -100,6 +100,34 @@ describe('presentRendererRecoveryPrompt', () => { expect(shown[0].detail).not.toContain('times in a row') }) + it('says Windows is out of memory with the commit left, instead of blaming drivers', async () => { + const { run, shown, reload } = harness({ + failure: 'low-commit', + availableCommitMB: 60, + responses: [0] + }) + await run() + expect(shown[0].message).toBe('Windows is out of memory.') + expect(shown[0].detail).toContain('only 60 MB of memory left') + expect(shown[0].detail).toContain('increase the Windows page file size') + expect(shown[0].detail).not.toContain('graphics') + expect(shown[0].buttons).toEqual(['Reload', 'Quit']) + expect(reload).toHaveBeenCalledOnce() + }) + + it('omits Copy Commands on low commit, since its detail would not be shown', async () => { + const { run, shown, quit, copied } = harness({ + failure: 'low-commit', + availableCommitMB: 60, + diagnose: () => POISON, + responses: [1] + }) + await run() + expect(shown[0].buttons).toEqual(['Reload', 'Quit']) + expect(copied).toEqual([]) + expect(quit).toHaveBeenCalledOnce() + }) + it('quits on the last button', async () => { const { run, reload, quit } = harness({ responses: [1] }) await run() diff --git a/src/main/window/renderer-recovery-prompt.ts b/src/main/window/renderer-recovery-prompt.ts index 18ab02a8eca..bd8b3b56b52 100644 --- a/src/main/window/renderer-recovery-prompt.ts +++ b/src/main/window/renderer-recovery-prompt.ts @@ -8,6 +8,8 @@ export type RendererRecoveryPromptFailure = RecoveryExhaustionCause export type RendererRecoveryPromptDeps = { recentRecoveryCount: number failure?: RendererRecoveryPromptFailure + /** Shown with failure 'low-commit'. */ + availableCommitMB?: number isQuitting: () => boolean diagnose: () => InstallDirAclPoisonDiagnosis | null showMessageBox: (options: MessageBoxOptions) => Promise @@ -20,33 +22,46 @@ export async function presentRendererRecoveryPrompt( deps: RendererRecoveryPromptDeps ): Promise { const stalled = deps.failure === 'reload-stalled' + const lowCommit = deps.failure === 'low-commit' // Copying must preserve the only available recovery surface. while (!deps.isQuitting()) { - const diagnosis = deps.diagnose() + // Why skip: the low-commit text replaces diagnosis.detail, which would leave Copy Commands unexplained. + const diagnosis = lowCommit ? null : deps.diagnose() const buttons = [translateMain('rendererRecovery.reload', 'Reload')] if (diagnosis) { buttons.push(translateMain('rendererRecovery.copyCommands', 'Copy Commands')) } buttons.push(translateMain('rendererRecovery.quit', 'Quit')) - const recoveryDetail = stalled + const recoveryDetail = lowCommit ? translateMain( - 'rendererRecovery.stalledDetail', - 'Orca reloaded the window after a crash, but it never finished loading.' + 'rendererRecovery.lowCommitDetail', + 'Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.', + { availableMB: deps.availableCommitMB ?? 0 } ) - : translateMain( - 'rendererRecovery.crashLoopDetail', - 'Orca tried to recover {{recoveryCount}} times in a row without success.', - { recoveryCount: deps.recentRecoveryCount } - ) - const causeDetail = diagnosis - ? `${diagnosis.detail}\n\n${translateMain( - 'rendererRecovery.driverFallback', - 'If that does not help, the cause is usually a graphics driver.' - )}` - : translateMain( - 'rendererRecovery.genericDetail', - 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' + : stalled + ? translateMain( + 'rendererRecovery.stalledDetail', + 'Orca reloaded the window after a crash, but it never finished loading.' + ) + : translateMain( + 'rendererRecovery.crashLoopDetail', + 'Orca tried to recover {{recoveryCount}} times in a row without success.', + { recoveryCount: deps.recentRecoveryCount } + ) + const causeDetail = lowCommit + ? translateMain( + 'rendererRecovery.lowCommitAdvice', + 'Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.' ) + : diagnosis + ? `${diagnosis.detail}\n\n${translateMain( + 'rendererRecovery.driverFallback', + 'If that does not help, the cause is usually a graphics driver.' + )}` + : translateMain( + 'rendererRecovery.genericDetail', + 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' + ) const { response } = await deps.showMessageBox({ type: 'error', buttons, @@ -54,15 +69,17 @@ export async function presentRendererRecoveryPrompt( // Escape retries instead of destroying the session. cancelId: 0, title: translateMain('rendererRecovery.title', 'Orca keeps failing to load'), - message: stalled - ? translateMain( - 'rendererRecovery.stalledMessage', - 'The app window stopped responding while reloading after a crash.' - ) - : translateMain( - 'rendererRecovery.crashLoopMessage', - 'The app window crashed repeatedly and stopped reloading automatically.' - ), + message: lowCommit + ? translateMain('rendererRecovery.lowCommitMessage', 'Windows is out of memory.') + : stalled + ? translateMain( + 'rendererRecovery.stalledMessage', + 'The app window stopped responding while reloading after a crash.' + ) + : translateMain( + 'rendererRecovery.crashLoopMessage', + 'The app window crashed repeatedly and stopped reloading automatically.' + ), detail: `${recoveryDetail}\n\n${causeDetail}` }) if (response === 1 && diagnosis) { diff --git a/src/main/window/renderer-recovery-reload-watchdog.ts b/src/main/window/renderer-recovery-reload-watchdog.ts index 1295ca13ac4..f2f013d18e0 100644 --- a/src/main/window/renderer-recovery-reload-watchdog.ts +++ b/src/main/window/renderer-recovery-reload-watchdog.ts @@ -1,6 +1,7 @@ import { is } from '@electron-toolkit/utils' import type { BrowserWindow } from 'electron' import { isSystemSessionEnding } from '../crash-reporting/expected-teardown-state' +import type { LowCommitOomVerdict } from '../crash-reporting/low-commit-oom-recovery-gate' import type { CreateMainWindowOptions, MainWindowLoadObserver } from './main-window-contracts' import { mainWindowLoadErrorCode } from './main-window-load-error-code' @@ -24,7 +25,7 @@ const MILESTONE_RANK: Record = { 'dom-ready': 2 } -export type RecoveryExhaustionCause = 'crash-loop' | 'reload-stalled' +export type RecoveryExhaustionCause = 'crash-loop' | 'reload-stalled' | 'low-commit' export type RendererRecoveryReloadWatchdog = { /** Issues a recovery reload and arms the stall watchdog. */ @@ -34,7 +35,11 @@ export type RendererRecoveryReloadWatchdog = { trigger?: RecoveryReloadTrigger ) => void /** Raises the recovery prompt at most once: a native message box cannot be dismissed, so a second one stacks. */ - escalate: (subject: RecoveryPromptSubject, cause: RecoveryExhaustionCause) => void + escalate: ( + subject: RecoveryPromptSubject, + cause: RecoveryExhaustionCause, + lowCommit?: LowCommitOomVerdict + ) => void /** * A main-frame document finished loading. Only an attempt whose load was superseded takes this as its outcome; * every other attempt settles through its own load promise, which an error page or a later navigation cannot fool. @@ -197,7 +202,11 @@ export function createRendererRecoveryReloadWatchdog(args: { ) } - const escalate = (subject: RecoveryPromptSubject, cause: RecoveryExhaustionCause): void => { + const escalate = ( + subject: RecoveryPromptSubject, + cause: RecoveryExhaustionCause, + lowCommit?: LowCommitOomVerdict + ): void => { // A new crash invalidates any document that landed while the prompt was open. documentLanded = false if (prompt) { @@ -209,6 +218,7 @@ export function createRendererRecoveryReloadWatchdog(args: { webContentsId: rendererWebContentsId, recentRecoveryCount: subject.recentRecoveryCount, cause, + ...(lowCommit ? { lowCommit } : {}), // Watch manual retries too, so another stall can offer recovery again. retry: () => retryFrom(subject) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e2a1494d6eb..1fd38350c69 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18539,7 +18539,10 @@ "genericDetail": "This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.", "title": "Orca keeps failing to load", "stalledMessage": "The app window stopped responding while reloading after a crash.", - "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically." + "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically.", + "lowCommitMessage": "Windows is out of memory.", + "lowCommitDetail": "Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.", + "lowCommitAdvice": "Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload." }, "notifications": { "agentStatus": { From 53930a161be4dd4420e9c71be0dadac6e9388752 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:21:11 -0700 Subject: [PATCH 23/26] Keep SSH typing replies visible during background pressure (#24629) * test: align source-control fixtures with current store contracts * Bound E2E package setup and retain cancelled-job traces * Remove empty passing sentinels from opt-in socket tests * Make SSH typing pressure fixture readiness and replies observable --- .../e2e/helpers/remote-typing-load-script.ts | 22 ++++++++ .../remote-typing-load-script.unit.test.ts | 56 +++++++++++++++++++ tests/e2e/ssh-docker-relay-perf.spec.ts | 32 ++--------- 3 files changed, 84 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/helpers/remote-typing-load-script.ts create mode 100644 tests/e2e/helpers/remote-typing-load-script.unit.test.ts diff --git a/tests/e2e/helpers/remote-typing-load-script.ts b/tests/e2e/helpers/remote-typing-load-script.ts new file mode 100644 index 00000000000..f2e2c296c53 --- /dev/null +++ b/tests/e2e/helpers/remote-typing-load-script.ts @@ -0,0 +1,22 @@ +export function remoteTypingLoadScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + 'const statusRow = Math.max(2, process.stdout.rows || 24)', + "process.stdout.write('\\x1b[1;' + (statusRow - 1) + 'r')", + 'let seq = 0', + 'let frame = 0', + 'let bg = null', + `process.stdout.write('REMOTE_TUI_READY_${runId}\\n')`, + "setTimeout(() => { bg = setInterval(() => { frame += 1; process.stdout.write('\\x1b[1;1HBG_' + frame + '_' + 'x'.repeat(4096) + '\\n') }, 8) }, 500)", + "process.stdin.on('data', (chunk) => {", + ' if (chunk.includes(String.fromCharCode(3))) { if (bg) clearInterval(bg); process.exit(0) }', + ' for (const char of chunk) {', + " if (char === '\\r' || char === '\\n') continue", + ' seq += 1', + ` process.stdout.write('\\x1b[' + statusRow + ';2H\\x1b[2KREMOTE_KEY_${runId}_' + seq + '_' + char)`, + ' }', + '})' + ].join(';') +} diff --git a/tests/e2e/helpers/remote-typing-load-script.unit.test.ts b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts new file mode 100644 index 00000000000..2b06a5897ca --- /dev/null +++ b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts @@ -0,0 +1,56 @@ +import '../../../src/main/daemon/xterm-env-polyfill' +import { EventEmitter } from 'node:events' +import { runInNewContext } from 'node:vm' +import { Terminal } from '@xterm/headless' +import { expect, it } from 'vitest' +import { remoteTypingLoadScript } from './remote-typing-load-script' + +it.each([12, 24, 40])( + 'keeps typed output visible through background pressure at %i rows', + async (rows) => { + const terminal = new Terminal({ rows, cols: 80, scrollback: 1000, allowProposedApi: true }) + const input = new EventEmitter() + const chunks: string[] = [] + let background = (): void => { + throw new Error('Background pressure did not start') + } + try { + runInNewContext(remoteTypingLoadScript('test'), { + process: { + stdin: { + isTTY: true, + setEncoding() {}, + setRawMode() {}, + resume() {}, + on: input.on.bind(input) + }, + stdout: { rows, write: (chunk: string) => chunks.push(chunk) }, + exit() {} + }, + setTimeout: (callback: () => void) => callback(), + setInterval: (callback: () => void) => { + background = callback + return 1 + }, + clearInterval() {} + }) + input.emit('data', 'ab\r\ncd') + for (let frame = 0; frame < 40; frame += 1) { + background() + } + const output = chunks.join('') + expect(output.length).toBeGreaterThan(160_000) + await new Promise((resolve) => terminal.write(output, resolve)) + const screen = Array.from( + { length: rows }, + (_, row) => + terminal.buffer.active + .getLine(terminal.buffer.active.baseY + row) + ?.translateToString(true) ?? '' + ).join('\n') + expect(screen).toContain('REMOTE_KEY_test_4_d') + } finally { + terminal.dispose() + } + } +) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index cc7f5efbc38..2a1e00ba265 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -1,3 +1,4 @@ +import { remoteTypingLoadScript } from './helpers/remote-typing-load-script' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' @@ -56,27 +57,6 @@ function encodedRemoteNodeCommand(script: string): string { return `node -e ${shellQuote(`eval(Buffer.from('${encoded}', 'base64').toString('utf8'))`)}` } -function remoteTypingLoadScript(runId: string): string { - return [ - "process.stdin.setEncoding('utf8')", - 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', - 'process.stdin.resume()', - 'let seq = 0', - 'let frame = 0', - 'let bg = null', - `process.stdout.write('REMOTE_TUI_READY_${runId}\\n')`, - "setTimeout(() => { bg = setInterval(() => { frame += 1; process.stdout.write('BG_' + frame + '_' + 'x'.repeat(4096) + '\\n') }, 8) }, 500)", - "process.stdin.on('data', (chunk) => {", - ' if (chunk.includes(String.fromCharCode(3))) { if (bg) clearInterval(bg); process.exit(0) }', - ' for (const char of chunk) {', - " if (char === '\\r' || char === '\\n') continue", - ' seq += 1', - ` process.stdout.write('\\x1b[20;2HREMOTE_KEY_${runId}_' + seq + '_' + char + '\\n')`, - ' }', - '})' - ].join(';') -} - function remoteBackgroundFloodScript(runId: string): string { return [ "process.stdin.setEncoding('utf8')", @@ -167,7 +147,7 @@ test.describe('Docker SSH relay perf', () => { const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) const runId = String(Date.now()) - await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteTypingLoadScript(runId))}`) + await execInTerminal(orcaPage, ptyId, encodedRemoteNodeCommand(remoteTypingLoadScript(runId))) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${runId}`, 30_000, 80_000) const measurement = await measureRemoteTyping(orcaPage, ptyId, runId) const summary = `median=${measurement.medianLatencyMs.toFixed( @@ -208,7 +188,7 @@ test.describe('Docker SSH relay perf', () => { await execInTerminal( orcaPage, backgroundPtyId, - `node -e ${shellQuote(remoteBackgroundFloodScript(runId))}` + encodedRemoteNodeCommand(remoteBackgroundFloodScript(runId)) ) await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) @@ -226,7 +206,7 @@ test.describe('Docker SSH relay perf', () => { await execInTerminal( orcaPage, activePtyId, - `node -e ${shellQuote(remoteTypingLoadScript(activeRunId))}` + encodedRemoteNodeCommand(remoteTypingLoadScript(activeRunId)) ) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${activeRunId}`, 30_000, 80_000) const heldAckPressure = expect.poll( @@ -294,11 +274,11 @@ test.describe('Docker SSH relay perf', () => { orcaPage, ptyId, `dd if=/dev/urandom of=${shellQuote(loadFile)} bs=1M count=8 status=none && ` + - `echo LOAD_FILES_READY_${runId}` + `echo LOAD_FILES_READY_'${runId}'` ) await waitForTerminalOutput(orcaPage, `LOAD_FILES_READY_${runId}`, 60_000, 80_000) - await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteTypingLoadScript(runId))}`) + await execInTerminal(orcaPage, ptyId, encodedRemoteNodeCommand(remoteTypingLoadScript(runId))) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${runId}`, 30_000, 80_000) // Background relay pressure: continuous large file reads plus git status From 6153fbcfe465e2539b6fb17a9202ac19a4bb4d3a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:42:41 -0700 Subject: [PATCH 24/26] Reduce redundant headless server CI work (#24527) * ci: avoid unrelated headless server qualification * ci: skip headless detection for ineligible draft PRs * ci: preserve cross-host qualification and skip supplied prerequisites * ci: include Windows server cache validation in change detection --- .github/workflows/node-server-tests.yml | 55 +++++++++--- config/scripts/node-server-change-scope.mjs | 5 +- .../scripts/node-server-change-scope.test.mjs | 11 ++- .../scripts/node-server-concurrency.test.mjs | 85 +++++++++++++++++++ config/scripts/node-server-qualification.mjs | 74 ++++++++++------ .../node-server-qualification.test.mjs | 85 ++++++++++++++++++- .../orcad-template-release-workflow.test.mjs | 4 +- .../orcad-windows-prebuild-workflow.test.mjs | 2 +- docs/reference/ci-runner-efficiency.md | 41 ++++++++- 9 files changed, 317 insertions(+), 45 deletions(-) create mode 100644 config/scripts/node-server-concurrency.test.mjs diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 0a2840c60a1..449ad212864 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -18,8 +18,7 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/actions/prepare-native-runtime/**' - '.github/workflows/node-server-tests.yml' - # The pull request qualifies one platform for an unflavoured change; this is where all six - # are re-qualified, so a platform break surfaces minutes after merge instead of next cron. + # Relevant main pushes qualify every platform after the dependency check. push: branches: [main] paths: @@ -60,15 +59,16 @@ on: permissions: contents: read -# Why a run-scoped group for template builds: a release call shares github.ref with main's push -# runs, and cancelling either would drop a release's template or a main qualification. +# Main pushes must finish detection before they can supersede relevant qualification. concurrency: - group: node-server-${{ inputs.build_template && format('template-{0}', github.run_id) || github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ !inputs.build_template }} + group: node-server-${{ (inputs.build_template || github.event_name == 'push') && format('run-{0}', github.run_id) || github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ !inputs.build_template && github.event_name != 'push' }} jobs: changes: - if: github.event_name == 'pull_request' + if: >- + github.event_name == 'push' || + (github.event_name == 'pull_request' && github.event.pull_request.draft != true) runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -84,7 +84,20 @@ jobs: - name: Detect headless-server build and test inputs id: scope shell: bash + env: + PUSH_BASE: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} run: | + if [ "$EVENT_NAME" = push ]; then + # Compare the entire push, including multi-commit pushes and removed files. + if git fetch --no-tags --depth=1 origin "$PUSH_BASE" && + git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + else + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + exit 0 + fi # Compare the tested merge with its base, retaining both sides of renames. if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" @@ -94,6 +107,9 @@ jobs: persistence: needs: changes + concurrency: + group: node-server-persistence-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # Missing/failed detection runs the full matrix; manual runs remain unconditional. # A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against # the event name so the push and schedule paths do not rest on a null property comparison. @@ -126,10 +142,10 @@ jobs: continue-on-error: true shell: bash run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint - - name: Restore the exact Windows server prebuild for this pull request + - name: Restore the exact Windows server prebuild id: orcad-prebuild-cache-restore if: >- - github.event_name == 'pull_request' && + (github.event_name == 'pull_request' || github.event_name == 'push') && steps.orcad-prebuild-cache-identity.outcome == 'success' && steps.orcad-prebuild-cache-identity.outputs.key != '' continue-on-error: true @@ -234,6 +250,9 @@ jobs: linux_glibc_floor: needs: [changes, persistence] + concurrency: + group: node-server-linux_glibc_floor-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && @@ -262,7 +281,17 @@ jobs: PYTHON: /opt/python/cp312-cp312/bin/python3 steps: - name: Install glibc 2.28 prerequisites - run: dnf install -y git procps-ng unzip which xz + run: | + missing_tool=false + for tool in git ps unzip which xz; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_tool=true + fi + done + if [ "$missing_tool" = true ]; then + # The image's source-built Git needs no RPM; missing tools come from AlmaLinux. + dnf --disablerepo='epel*' install -y git procps-ng unzip which xz + fi - uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} @@ -289,6 +318,9 @@ jobs: linux_glibc217_compat: needs: [changes, persistence] + concurrency: + group: node-server-linux_glibc217_compat-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && @@ -341,6 +373,9 @@ jobs: linux_musl: needs: [changes, persistence] + concurrency: + group: node-server-linux_musl-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index d190855f7c0..342e5ad7c3a 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -17,6 +17,7 @@ const BUILD_SCRIPTS = [ 'config/scripts/pinned-node-downloads.mjs', 'config/scripts/build-orcad.mjs', 'config/scripts/build-orcad-prebuilds.mjs', + 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/orcad-prebuild-smoke-child.cjs', 'config/scripts/build-windows-process-tree-relay-addon.mjs', 'config/scripts/run-node-server-tests.mjs', @@ -133,7 +134,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) const result = await classifyNodeServerChanges(changedFiles) console.log(result.reason) - const policy = nodeServerQualification(changedFiles, result) + const policy = nodeServerQualification(changedFiles, result, { + fullQualification: process.argv.includes('--full-qualification') + }) const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 78b511dc1a8..6c1903f8031 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -113,6 +113,7 @@ describe('the actual Bun build and profile-test dependency graph', () => { 'src/main/worker-thread-entry-path.ts', 'config/scripts/zip-extractor-command.mjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', + 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/profile-state-worker-smoke.mjs', 'config/scripts/vitest-host-ports-setup.ts', 'tests/e2e/daemon-running-work-probe.unit.test.ts' @@ -131,16 +132,22 @@ describe('the actual Bun build and profile-test dependency graph', () => { }) }) -it('keeps all ten platform jobs and runs them when detection is skipped or fails', () => { +it('keeps every platform job and runs them when detection is skipped or fails', () => { const workflow = parse( readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8') ) expect(workflow.on).toHaveProperty('workflow_dispatch') - expect(workflow.jobs.changes.if).toBe("github.event_name == 'pull_request'") + expect(workflow.jobs.changes.if).toBe( + "github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft != true)" + ) expect(workflow.jobs.changes.steps[0].with['fetch-depth']).toBe(2) expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false) const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope') expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD') + expect(detect.env.PUSH_BASE).toBe('${{ github.event.before }}') + expect(detect.run).toContain('git fetch --no-tags --depth=1 origin "$PUSH_BASE"') + expect(detect.run).toContain('git diff --name-only --no-renames -z "$PUSH_BASE" HEAD') + expect(detect.run).toContain('node-server-changes" --full-qualification') expect(workflow.on.pull_request.types).toContain('ready_for_review') expect(workflow.on.schedule).toHaveLength(1) // A pull request may qualify one platform, so the merged commit must re-qualify all six. diff --git a/config/scripts/node-server-concurrency.test.mjs b/config/scripts/node-server-concurrency.test.mjs new file mode 100644 index 00000000000..92c863361d5 --- /dev/null +++ b/config/scripts/node-server-concurrency.test.mjs @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { expect, it } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) + +function context(event, runId, inputs = {}) { + return { + github: { + event_name: event, + run_id: runId, + ref: 'refs/heads/main', + event: { pull_request: { number: 123 } } + }, + inputs: { build_template: false, ref: '', ...inputs }, + matrix: { os: 'windows-2022' }, + format: (template, value) => template.replace('{0}', value) + } +} + +function expression(source, ctx) { + return runInNewContext(source.slice(3, -2).trim(), ctx) +} + +function group(policy, ctx) { + return policy.group.replace(/\$\{\{([\s\S]*?)\}\}/g, (_match, source) => + String(runInNewContext(source, ctx)) + ) +} + +it('skips draft detection and rechecks the same draft once it is ready', () => { + const draft = context('pull_request', 1) + draft.github.event.pull_request.draft = true + expect(runInNewContext(workflow.jobs.changes.if, draft)).toBe(false) + draft.github.event.pull_request.draft = false + expect(runInNewContext(workflow.jobs.changes.if, draft)).toBe(true) + expect(workflow.on.pull_request.types).toContain('ready_for_review') + expect(runInNewContext(workflow.jobs.changes.if, context('push', 2))).toBe(true) + for (const event of ['schedule', 'workflow_dispatch', 'workflow_call']) { + expect(runInNewContext(workflow.jobs.changes.if, context(event, 2))).toBe(false) + } +}) + +it('lets main pushes finish detection without cancelling another push', () => { + const first = context('push', 1) + const second = context('push', 2) + expect(group(workflow.concurrency, first)).not.toBe(group(workflow.concurrency, second)) + expect(expression(workflow.concurrency['cancel-in-progress'], first)).toBe(false) +}) + +it('still replaces superseded pull requests at workflow level', () => { + const first = context('pull_request', 1) + const second = context('pull_request', 2) + expect(group(workflow.concurrency, first)).toBe(group(workflow.concurrency, second)) + expect(expression(workflow.concurrency['cancel-in-progress'], first)).toBe(true) +}) + +it.each(['persistence', 'linux_glibc_floor', 'linux_glibc217_compat', 'linux_musl'])( + '%s only supersedes eligible main qualification, isolating releases and nightly runs', + (name) => { + const job = workflow.jobs[name] + expect(job.if).toContain("needs.changes.outputs.should_run != 'false'") + const policy = job.concurrency + const first = context('push', 1) + const second = context('push', 2) + expect(group(policy, first)).toBe(group(policy, second)) + expect(expression(policy['cancel-in-progress'], first)).toBe(true) + for (const [event, inputs] of [ + ['schedule', {}], + ['workflow_dispatch', {}], + ['push', { build_template: true }], + ['push', { ref: 'refs/tags/v1' }] + ]) { + const isolated = context(event, 2, inputs) + expect(group(policy, isolated)).not.toBe(group(policy, first)) + expect(expression(policy['cancel-in-progress'], isolated)).toBe(false) + expect(group(policy, isolated)).not.toBe(group(policy, context(event, 3, inputs))) + } + if (job.strategy?.matrix) { + second.matrix.os = 'windows-11-arm' + expect(group(policy, second)).not.toBe(group(policy, first)) + } + } +) diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index b1faf6eef17..dc409a1c84d 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -7,42 +7,66 @@ export const NODE_SERVER_RUNNERS = [ 'windows-11-arm' ] -// Only surfaces whose behaviour actually differs per platform. Escalating on `config/`, -// `resources/` and `.github/` wholesale took 36.5% of the last 1100 commits through all six -// platforms where a platform-flavoured predicate takes 19%. -const PLATFORM_PREFIXES = [ +// Shared execution and storage changes need every host; explicit platform paths need their family. +const BUILD_PREFIXES = [ 'native/', 'config/patches/', '.github/actions/install-node-dependencies/', - '.github/actions/prepare-native-runtime/', + '.github/actions/prepare-native-runtime/' +] +// A remote target's OS does not identify the client platform that builds its commands. +const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/'] +const PLATFORM_PREFIXES = [ 'src/main/persistence/', 'src/main/sqlite/', 'src/main/orcad/', - 'src/main/providers/', 'src/main/daemon/', - 'src/main/ssh/', 'src/main/wsl/', - 'src/relay/', - 'src/shared/child-process/', - // Every native prebuild slot is compiled and smoked against the pinned runtime. - 'src/shared/node-runtime-pin.ts' + 'src/shared/child-process/' ] -export function nodeServerQualification(changedFiles, scope) { - const platformSpecific = changedFiles.some( - (file) => - // A root manifest can move a native dependency on every platform at once. +const PLATFORM_FAMILIES = [ + { pattern: /(?:^|[/.-])(?:windows|win32|wsl)(?:[/.-]|$)/i, prefix: 'windows-' }, + { pattern: /(?:^|[/.-])(?:macos|darwin|posix)(?:[/.-]|$)/i, prefix: 'macos-' }, + { pattern: /(?:^|[/.-])(?:linux|posix)(?:[/.-]|$)/i, prefix: 'ubuntu-' } +] + +export function nodeServerQualification(changedFiles, scope, { fullQualification = false } = {}) { + const selected = new Set(['ubuntu-22.04']) + let qualification = false + let full = fullQualification || changedFiles.length === 0 || scope.graphUnavailable === true + for (const file of changedFiles) { + // Build policy and native sources can change every slot, even with a platform in the name. + if ( !file.includes('/') || - PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix)) || - /(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun|prebuilds?)(?:[/.-]|$)/i.test( - file - ) - ) - // A pull request qualifies one platform unless the change is platform-flavoured; the push to - // main re-qualifies all six, so an unescalated miss surfaces minutes after merge, not a day. - const full = changedFiles.length === 0 || scope.graphUnavailable === true || platformSpecific + BUILD_PREFIXES.some((prefix) => file.startsWith(prefix)) || + CROSS_HOST_PREFIXES.some((prefix) => file.startsWith(prefix)) || + (/(?:^|[/.-])(?:remote|ssh)(?:[/.-]|$)/i.test(file) && + PLATFORM_FAMILIES.some(({ pattern }) => pattern.test(file))) || + file === 'src/shared/node-runtime-pin.ts' || + file === '.github/workflows/node-server-tests.yml' || + file.startsWith('config/scripts/node-server-') || + /(?:^|[/.-])(?:bun|prebuilds?)(?:[/.-]|$)/i.test(file) + ) { + full = true + continue + } + const families = PLATFORM_FAMILIES.filter(({ pattern }) => pattern.test(file)) + if (families.length > 0) { + for (const { prefix } of families) { + for (const runner of NODE_SERVER_RUNNERS.filter((runner) => runner.startsWith(prefix))) { + selected.add(runner) + } + qualification ||= prefix === 'ubuntu-' + } + } else if (PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix))) { + full = true + } + } return { - qualification: full, - runners: full ? NODE_SERVER_RUNNERS : ['ubuntu-22.04'] + qualification: full || qualification, + runners: full + ? NODE_SERVER_RUNNERS + : NODE_SERVER_RUNNERS.filter((runner) => selected.has(runner)) } } diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 480d7e45912..9c3c0bd7b27 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -34,11 +34,8 @@ it.each([ 'src/main/persistence/profile-state/store.ts', 'src/main/sqlite/database.ts', 'src/main/orcad/entry.ts', - 'src/main/runtime/windows-terminal.ts', - 'src/shared/linux-glibc.ts', 'src/main/daemon/entry.ts', 'src/relay/index.ts', - 'src/main/wsl/runner.ts', 'config/scripts/build-orcad-prebuilds.mjs', 'config/scripts/orcad-prebuild-slot-contents.mjs', 'src/shared/node-runtime-pin.ts' @@ -58,3 +55,85 @@ it('fails closed to every platform when the evidence is incomplete', () => { }).qualification ).toBe(true) }) + +it.each([ + [ + 'src/main/runtime/windows-terminal.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + [ + 'src/main/windows/windows-process-table.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + ['src/main/wsl/runner.ts', ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], false], + [ + 'src/main/orcad/orcad-launcher.win32.test.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + ['src/main/daemon/darwin-process.ts', ['ubuntu-22.04', 'macos-14', 'macos-15-intel'], false], + ['src/shared/linux-glibc.ts', ['ubuntu-22.04', 'ubuntu-24.04-arm'], true], + [ + 'src/main/daemon/posix-process.ts', + ['ubuntu-22.04', 'ubuntu-24.04-arm', 'macos-14', 'macos-15-intel'], + true + ] +])('selects both architectures and a Linux smoke for %s', (file, runners, qualification) => { + expect(nodeServerQualification([file], scope)).toEqual({ runners, qualification }) +}) + +it('combines platform families without adding Linux compatibility work', () => { + expect( + nodeServerQualification( + ['src/main/windows/windows-process-table.ts', 'src/main/daemon/darwin-process.ts'], + scope + ) + ).toEqual({ + runners: ['ubuntu-22.04', 'macos-14', 'macos-15-intel', 'windows-2022', 'windows-11-arm'], + qualification: false + }) +}) + +it('keeps all hosts for shared changes alongside a platform-specific change', () => { + expect( + nodeServerQualification( + ['src/main/windows/windows-process-table.ts', 'src/main/daemon/entry.ts'], + scope + ) + ).toEqual({ runners: NODE_SERVER_RUNNERS, qualification: true }) +}) + +it.each([ + 'src/main/ssh/remote-node-runtime-store-windows.ts', + 'src/main/ssh/orcad-remote-node-runtime-windows.ts', + 'src/main/ssh/ssh-posix-command-wrapper.test.ts', + 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', + 'src/relay/windows-port-scan.ts', + 'src/main/runtime/windows-firewall-remote-scope.ts', + 'src/shared/remote-windows-path.ts' +])('qualifies every client platform for a remote execution input: %s', (file) => { + expect(nodeServerQualification([file], scope)).toEqual({ + runners: NODE_SERVER_RUNNERS, + qualification: true + }) +}) + +it.each([ + '.github/workflows/node-server-tests.yml', + 'config/scripts/node-server-qualification.mjs' +])('qualifies all hosts when the selection policy changes: %s', (file) => { + expect(nodeServerQualification([file], scope)).toEqual({ + runners: NODE_SERVER_RUNNERS, + qualification: true + }) +}) + +it('fully qualifies relevant main pushes even for an unflavoured change', () => { + expect( + nodeServerQualification(['src/main/runtime/rpc/methods/example.ts'], scope, { + fullQualification: true + }) + ).toEqual({ runners: NODE_SERVER_RUNNERS, qualification: true }) +}) diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs index 87ea8570767..eee8d451fc3 100644 --- a/config/scripts/orcad-template-release-workflow.test.mjs +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -25,7 +25,9 @@ describe('orcad template release wiring (design D2)', () => { build_template: { type: 'boolean', default: false } }) // A release call shares github.ref with main's push runs; neither may cancel the other. - expect(nodeServer.concurrency['cancel-in-progress']).toBe('${{ !inputs.build_template }}') + expect(nodeServer.concurrency['cancel-in-progress']).toBe( + "${{ !inputs.build_template && github.event_name != 'push' }}" + ) expect(nodeServer.concurrency.group).toContain('github.run_id') for (const lane of LANES) { const steps = nodeServer.jobs[lane].steps diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index ef2371d86e5..3d42ffd8df3 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -40,7 +40,7 @@ function context(os, arch, event, ref, template = false) { describe('Windows server prebuild cache workflow', () => { it.each([ ['pull_request', 'refs/pull/1/merge', false, true, false], - ['push', 'refs/heads/main', false, false, true], + ['push', 'refs/heads/main', false, true, true], ['schedule', 'refs/heads/main', false, false, true], ['workflow_dispatch', 'refs/heads/main', false, false, true], ['workflow_call', 'refs/heads/main', false, false, false], diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index f4443b05c43..966cbcdbb10 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -3,6 +3,43 @@ The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks, unit-selection evidence, headless runtime qualification, review cancellation and daily occupancy reports. +## Headless server follow-up + +[PR #24527](https://github.com/stablyai/orca/pull/24527) adds dependency detection to +main pushes. Unrelated pushes skip qualification; relevant pushes still run all +six persistence targets and five Linux compatibility jobs. Explicit Windows or +Mac PR paths select both architectures plus a Linux smoke, while shared +execution/storage changes, SSH/provider/relay inputs, native inputs, manifests, and incomplete evidence +retain the full matrix. Main pushes use the same validated exact Windows slot +cache as PRs; nightly and release builds still compile freshly. + +Main detection runs cannot cancel each other. Only eligible qualification jobs +share main concurrency groups, so an unrelated push cannot cancel needed tests. +Release templates, explicit refs, and nightly runs remain isolated. + +Draft PRs have no server verdict, so their detector is also skipped. The existing +`ready_for_review` event performs detection and qualification once the PR is ready. +This removes the checkout and dependency installation for a result whose platform +jobs were already ineligible. + +The glibc 2.28 prerequisite step checks all five tools before installing anything. +The pinned ARM image already supplies them, including Git 2.55.0 built under +`/usr/local/bin`; installing the Git RPM does not change the Git on PATH. A +missing-tool fallback still installs the original package list and disables EPEL +for that one command. In +the baseline x64 log, EPEL metadata took 4 minutes 50 seconds to download although +every installed package came from AlmaLinux BaseOS or AppStream. The package list, +compiler image, libc floor, native smoke, and persistence tests stay unchanged. +The [DNF command reference](https://dnf.readthedocs.io/en/stable/command_ref.html) +defines `--disablerepo` as a temporary command-level filter, so later commands +retain the image's repository configuration. + +A [completed main run](https://github.com/stablyai/orca/actions/runs/36962172614) +used 42 aggregate runner-minutes across 11 test jobs. The +[latest daily demand report](https://github.com/stablyai/orca/actions/runs/36965354205) +estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are +baseline observations; post-merge savings have not yet been measured. + ## October 1 Windows and dependency cache follow-up [PR #24355](https://github.com/stablyai/orca/pull/24355) merged at `197ea3a3`. @@ -17,8 +54,8 @@ tooling is removed from ordinary PR CI. The existing dependency-native cache and the server's N-API 8 slot serve different consumers. Cache the small server slot separately, using the exact compiler image, architecture, dependency/patch/runtime inputs and compilation/validation source. -Only PR qualification restores it. Main qualification still compiles freshly and -saves after persistence/lifecycle tests and the existing x64 Node 18 handoff. +PR and main-push qualification restore it. Nightly qualification still compiles +freshly; main saves after persistence/lifecycle tests and the existing x64 Node 18 handoff. Templates and explicit-ref calls continue to compile freshly. | Hosted runner | Fresh build median | Restore median | Difference | From b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 01:50:08 -0700 Subject: [PATCH 25/26] fix: recover renderer launch failures in the running app (#24250) * fix(recovery): back off a launch-failed renderer instead of tripping the crash breaker A renderer that the OS refused to spawn (macOS exit 1003 = LAUNCH_RESULT_FAILURE; field cause: per-user process limit, posix_spawn EAGAIN) burned the 3-reload crash-loop budget in ~750ms and raised a "graphics driver" prompt, while the condition lasted minutes. - launch-failed retries in place on a 250ms..60s backoff (~2 min), outside the breaker; a loaded document resets it. Other crash reasons keep the breaker. - Each launch failure records renderer_launch_failed_probe {spawnError} from a cheap spawn probe, so bundles name EAGAIN/EACCES/ENOENT directly. - The exhausted prompt says the process limit was hit (probe EAGAIN), drops the graphics-driver wording, keeps Try Again as default, and offers no Restart: app.relaunch also needs a free process slot and silently fails without one. * fix(recovery): skip the launch probe on Windows and probe the prompt once - Re-check quitting after the prompt's probe; don't re-probe on Copy Commands. - recordRendererLaunchFailureProbe never rejects (breadcrumb write guarded). - Windows: no spawn probe; a child per failed launch is the per-operation burst EDR scores. * test: cover quitting and duplicate renderer launch failures * test: use typed access in PTY delay regression fixture * fix: scope extended launch retries to POSIX hosts * test: cover launch probe behavior on native Windows --------- Co-authored-by: m4air Co-authored-by: m4air --- src/main/startup/main-window-actions.ts | 4 +- src/main/startup/main-window-controller.ts | 18 +- ...MainWindow-renderer-crash-recovery.test.ts | 42 ---- ...MainWindow-renderer-launch-failure.test.ts | 237 ++++++++++++++++++ .../window/main-window-focus-lifecycle.ts | 20 +- .../renderer-launch-failure-backoff.test.ts | 25 ++ .../window/renderer-launch-failure-backoff.ts | 31 +++ ...enderer-launch-failure-probe.spawn.test.ts | 8 + .../renderer-launch-failure-probe.test.ts | 109 ++++++++ .../window/renderer-launch-failure-probe.ts | 69 +++++ .../window/renderer-recovery-prompt.test.ts | 87 +++++++ src/main/window/renderer-recovery-prompt.ts | 149 +++++++---- .../renderer-recovery-reload-watchdog.ts | 6 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../terminal-pty-write-spy.unit.test.ts | 11 +- 19 files changed, 748 insertions(+), 103 deletions(-) create mode 100644 src/main/window/createMainWindow-renderer-launch-failure.test.ts create mode 100644 src/main/window/renderer-launch-failure-backoff.test.ts create mode 100644 src/main/window/renderer-launch-failure-backoff.ts create mode 100644 src/main/window/renderer-launch-failure-probe.spawn.test.ts create mode 100644 src/main/window/renderer-launch-failure-probe.test.ts create mode 100644 src/main/window/renderer-launch-failure-probe.ts diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index 30cdfc9c477..c4303b79986 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -20,6 +20,7 @@ import { presentRendererRecoveryPrompt, type RendererRecoveryPromptFailure } from '../window/renderer-recovery-prompt' +import { probeRendererLaunchCapacity } from '../window/renderer-launch-failure-probe' // The window module injects this callback to avoid a cycle between actions and lifecycle code. let openWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow @@ -149,7 +150,7 @@ export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { webContents?.send('ui:openCrashReport') } -// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. +// Why: once auto-recovery gives up the window stays blank, so a main-process dialog is the only retry/quit surface. export async function showRendererRecoveryPrompt( recentRecoveryCount: number, failure?: RendererRecoveryPromptFailure, @@ -162,6 +163,7 @@ export async function showRendererRecoveryPrompt( ...(availableCommitMB === undefined ? {} : { availableCommitMB }), isQuitting: () => state.isQuitting, diagnose: describeInstallDirAclPoison, + probeLaunchCapacity: () => probeRendererLaunchCapacity(), showMessageBox: (options) => { const window = state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 70f06235019..941b3654f65 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -44,6 +44,7 @@ import { stopSyntheticTitleSpinnerTimer } from './synthetic-title-runtime' import { requireMainWindowServices } from './main-window-service-readiness' +import { recordRendererLaunchFailureProbe } from '../window/renderer-launch-failure-probe' const TRAY_CREATE_FALLBACK_MS = 12_000 const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 @@ -100,7 +101,7 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} state.isQuitting = false clearExpectedRendererReload() }, - onRendererProcessGone: (details, webContentsId) => + onRendererProcessGone: (details, webContentsId) => { recordProcessGoneCrash( 'renderer', 'renderer', @@ -108,7 +109,12 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} details.exitCode ?? null, { processType: 'renderer' }, webContentsId - ), + ) + // Why: launch-failed only says a spawn failed; the probe's errno (EAGAIN = process limit) names the cause. + if (details.reason === 'launch-failed' && !state.isQuitting) { + void recordRendererLaunchFailureProbe(details) + } + }, shouldRecoverRenderer: (details, webContentsId) => shouldRecoverRendererAfterProcessGone({ reason: details.reason, @@ -119,9 +125,11 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} recordDurableCrashBreadcrumb( cause === 'reload-stalled' ? 'renderer_recovery_reload_exhausted' - : cause === 'low-commit' - ? 'renderer_recovery_low_commit_prompt' - : 'renderer_recovery_circuit_breaker_open', + : cause === 'launch-failed' + ? 'renderer_recovery_launch_backoff_exhausted' + : cause === 'low-commit' + ? 'renderer_recovery_low_commit_prompt' + : 'renderer_recovery_circuit_breaker_open', { reason: details.reason, exitCode: details.exitCode ?? null, diff --git a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts index b9f425f027b..0f22c3cc644 100644 --- a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts +++ b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts @@ -598,46 +598,4 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) - - it('bounds renderer launch-failed recovery with the crash-loop breaker', () => { - vi.useFakeTimers() - - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - const onRendererRecoveryExhausted = vi.fn() - const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() - - try { - createMainWindow(null, { - onRendererRecoveryExhausted, - shouldRecoverRenderer: (details) => - shouldRecoverRendererAfterProcessGone({ - reason: details.reason, - expectedTeardown: 'none' - }) - }) - - const details = { - reason: 'launch-failed', - exitCode: 18 - } as Electron.RenderProcessGoneDetails - const driveLaunchFailure = (): void => { - windowHandlers['render-process-gone']?.({} as never, details) - vi.advanceTimersByTime(250) - } - - driveLaunchFailure() - driveLaunchFailure() - driveLaunchFailure() - expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) - - driveLaunchFailure() - expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) - expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() - expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( - expect.objectContaining({ details, recentRecoveryCount: 3 }) - ) - } finally { - consoleError.mockRestore() - } - }) }) diff --git a/src/main/window/createMainWindow-renderer-launch-failure.test.ts b/src/main/window/createMainWindow-renderer-launch-failure.test.ts new file mode 100644 index 00000000000..7befc2fe588 --- /dev/null +++ b/src/main/window/createMainWindow-renderer-launch-failure.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', async () => + (await import('./createMainWindow-test-harness')).electronModuleMock() +) +vi.mock('@electron-toolkit/utils', async () => + (await import('./createMainWindow-test-harness')).electronToolkitUtilsMock() +) +vi.mock('./macos-tahoe-release', async () => + (await import('./createMainWindow-test-harness')).macosTahoeReleaseMock() +) +vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock()) +vi.mock('../browser/browser-manager', async () => + (await import('./createMainWindow-test-harness')).browserManagerMock() +) +vi.mock('../browser/browser-client-page-renderer-runtime', async () => { + const harness = await import('./createMainWindow-test-harness') + return { + attachBrowserClientPageRenderer: harness.attachClientPageRendererMock, + retireBrowserClientPageRenderer: harness.retireClientPageRendererMock + } +}) + +import { createMainWindow } from './createMainWindow' +import type { CreateMainWindowOptions } from './main-window-contracts' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { + browserWindowMock, + resetMainWindowMocks, + withPlatform +} from './createMainWindow-test-harness' +import { RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS } from './renderer-launch-failure-backoff' + +// macOS LAUNCH_RESULT_FAILURE: posix_spawn of the Renderer helper failed (field: EAGAIN, per-user process limit). +const LAUNCH_FAILED: Electron.RenderProcessGoneDetails = { reason: 'launch-failed', exitCode: 1003 } +const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 } + +/** + * Field shape (v1.4.218, bundle F0C6NHQF4C8): while the OS refuses spawns, every load emits launch-failed and then + * rejects ERR_FAILED within ~10ms, before any did-finish-load. Once headroom returns the same webContents loads. + */ +function createSpawnRefusingWindow(platform: NodeJS.Platform = 'darwin') { + const handlers: Record void> = {} + const spawn = { refused: true } + const webContents = { + id: 143, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const load = vi.fn((): Promise => { + if (!spawn.refused) { + queueMicrotask(() => handlers['did-finish-load']?.()) + return Promise.resolve() + } + queueMicrotask(() => + withPlatform(platform, () => handlers['render-process-gone']?.({}, LAUNCH_FAILED)) + ) + return Promise.reject( + new Error("ERR_FAILED (-2) loading 'file:///opt/orca/renderer/index.html'") + ) + }) + browserWindowMock.mockImplementation(function () { + return { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: load, + loadURL: load + } + }) + return { handlers, load, spawn } +} + +function open(overrides: CreateMainWindowOptions = {}) { + const onRendererRecoveryExhausted = vi.fn() + const onRecoveryReloadOutcome = vi.fn() + createMainWindow(null, { + onRendererRecoveryExhausted, + onRecoveryReloadOutcome, + shouldRecoverRenderer: (details) => + shouldRecoverRendererAfterProcessGone({ reason: details.reason, expectedTeardown: 'none' }), + ...overrides + }) + return { onRendererRecoveryExhausted, onRecoveryReloadOutcome } +} + +const BACKOFF_TOTAL_MS = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + +describe('renderer launch-failed recovery', () => { + beforeEach(() => { + resetMainWindowMocks() + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('backs off instead of opening the crash-loop breaker after 3 quick failures', async () => { + const { load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledTimes(1) + + // Field: 3 reloads in ~750ms, then the breaker opened. Now retries spread out: 250ms, 1s, 2s, 4s, ... + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(999) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(load).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(2_000) + expect(load).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(4_000) + expect(load).toHaveBeenCalledTimes(5) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('recovers in place once spawn headroom returns, without any prompt', async () => { + const { handlers, load, spawn } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted, onRecoveryReloadOutcome } = open() + // Field: the process table stayed full for minutes. + await vi.advanceTimersByTimeAsync(250 + 1_000 + 2_000 + 4_000 + 8_000 + 15_000) + expect(load).toHaveBeenCalledTimes(7) + spawn.refused = false + + await vi.advanceTimersByTimeAsync(30_000) + expect(load).toHaveBeenCalledTimes(8) + expect(onRecoveryReloadOutcome).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'loaded' }) + ) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(load).toHaveBeenCalledTimes(8) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + + // A loaded document starts a fresh schedule: the next launch failure retries after 250ms again. + spawn.refused = true + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(9) + }) + + it('prompts only after the ~2 minute schedule is spent, then a failed manual retry re-prompts', async () => { + const { load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(BACKOFF_TOTAL_MS - 1) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + + // The last retry fails too; its prompt follows on the usual 250ms recovery tick. + await vi.advanceTimersByTimeAsync(251) + expect(BACKOFF_TOTAL_MS).toBeLessThanOrEqual(125_000) + expect(load).toHaveBeenCalledTimes(1 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + details: LAUNCH_FAILED, + cause: 'launch-failed', + recentRecoveryCount: RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length + }) + ) + + onRendererRecoveryExhausted.mock.calls[0]?.[0].retry() + expect(load).toHaveBeenCalledTimes(2 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + await vi.advanceTimersByTimeAsync(250) + expect(onRendererRecoveryExhausted).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(load).toHaveBeenCalledTimes(2 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + }) + + it('stops recovery when quitting during the long backoff', async () => { + const { load } = createSpawnRefusingWindow() + let quitting = false + const { onRendererRecoveryExhausted } = open({ getIsQuitting: () => quitting }) + await vi.advanceTimersByTimeAsync(250 + 1_000 + 2_000 + 4_000) + const loadsBeforeQuit = load.mock.calls.length + quitting = true + await vi.advanceTimersByTimeAsync(BACKOFF_TOTAL_MS * 2) + expect(load).toHaveBeenCalledTimes(loadsBeforeQuit) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('does not spend retry attempts on duplicate failure events', async () => { + const { handlers, load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(999) + expect(load).toHaveBeenCalledTimes(2) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('keeps Windows launch failures on the short recovery schedule', async () => { + const { load } = createSpawnRefusingWindow('win32') + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(1_000) + expect(load).toHaveBeenCalledTimes(4) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'launch-failed', recentRecoveryCount: 3 }) + ) + }) + + it('keeps the crash-loop breaker for renderers that actually crash', async () => { + const { handlers, load, spawn } = createSpawnRefusingWindow() + spawn.refused = false + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + for (let i = 0; i < 4; i += 1) { + handlers['render-process-gone']?.({}, CRASHED) + await vi.advanceTimersByTimeAsync(250) + } + expect(load).toHaveBeenCalledTimes(4) + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ details: CRASHED, cause: 'crash-loop', recentRecoveryCount: 3 }) + ) + }) +}) diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts index 44e8924cc1d..6b0f1fb1002 100644 --- a/src/main/window/main-window-focus-lifecycle.ts +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -23,6 +23,7 @@ import { } from '../browser/browser-client-page-renderer-runtime' import { registerRendererDocumentNavigation } from './renderer-document-navigation' import { createRendererRecoveryReloadWatchdog } from './renderer-recovery-reload-watchdog' +import { createRendererLaunchFailureBackoff } from './renderer-launch-failure-backoff' export type MainWindowFocusLifecycle = { dispose: () => void @@ -160,6 +161,7 @@ export function installMainWindowFocusLifecycle(args: { windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS, maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES }) + const launchFailureBackoff = createRendererLaunchFailureBackoff() const lowCommitOomGate = createLowCommitOomRecoveryGate() const clearRendererRecoveryTimer = (): void => { if (rendererRecoveryTimer) { @@ -189,6 +191,9 @@ export function installMainWindowFocusLifecycle(args: { ) { return } + const launchFailed = details.reason === 'launch-failed' + const useLaunchBackoff = launchFailed && process.platform !== 'win32' + const launchRetryDelayMs = useLaunchBackoff ? launchFailureBackoff.nextDelayMs() : null const goneAt = Date.now() // Why read at gone time: the sampler's next tick would see commit the corpse just released. const lowCommit = lowCommitOomGate.assess(details, goneAt) @@ -215,6 +220,16 @@ export function installMainWindowFocusLifecycle(args: { ) return } + // Why outside the breaker: a refused spawn is not a crash loop; its own bounded backoff owns the budget. + if (useLaunchBackoff) { + const subject = { details, recentRecoveryCount: launchFailureBackoff.attempts() } + if (launchRetryDelayMs === null) { + recoveryReloadWatchdog.escalate(subject, 'launch-failed') + } else { + recoveryReloadWatchdog.issue(details, subject.recentRecoveryCount) + } + return + } const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now()) if (!recovery.allowed) { // Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt. @@ -222,13 +237,13 @@ export function installMainWindowFocusLifecycle(args: { // recovery reload too — unwatched, one that stalls leaves a blank window and no further prompt. recoveryReloadWatchdog.escalate( { details, recentRecoveryCount: recovery.recentRecoveryCount }, - 'crash-loop' + launchFailed ? 'launch-failed' : 'crash-loop' ) return } // Why: a transient renderer/Network Service loss can blank Chromium; reload the app document once to recover. recoveryReloadWatchdog.issue(details, recovery.recentRecoveryCount) - }, 250) + }, launchRetryDelayMs ?? 250) } mainWindow.webContents.on('render-process-gone', (_event, details) => { rendererProcessGone = true @@ -259,6 +274,7 @@ export function installMainWindowFocusLifecycle(args: { rendererProcessGone = false attachBrowserClientPageRenderer(rendererWebContents) clearRendererRecoveryTimer() + launchFailureBackoff.reset() recoveryReloadWatchdog.notifyDocumentLoaded() }) diff --git a/src/main/window/renderer-launch-failure-backoff.test.ts b/src/main/window/renderer-launch-failure-backoff.test.ts new file mode 100644 index 00000000000..193e6bd4d20 --- /dev/null +++ b/src/main/window/renderer-launch-failure-backoff.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + createRendererLaunchFailureBackoff, + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS +} from './renderer-launch-failure-backoff' + +describe('createRendererLaunchFailureBackoff', () => { + it('keeps the 250ms first retry and spreads the rest over about two minutes', () => { + expect(RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS[0]).toBe(250) + const total = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + expect(total).toBeGreaterThanOrEqual(90_000) + expect(total).toBeLessThanOrEqual(125_000) + }) + + it('walks the schedule, then reports it spent until reset', () => { + const backoff = createRendererLaunchFailureBackoff([10, 20]) + expect(backoff.nextDelayMs()).toBe(10) + expect(backoff.nextDelayMs()).toBe(20) + expect(backoff.nextDelayMs()).toBeNull() + expect(backoff.attempts()).toBe(2) + backoff.reset() + expect(backoff.attempts()).toBe(0) + expect(backoff.nextDelayMs()).toBe(10) + }) +}) diff --git a/src/main/window/renderer-launch-failure-backoff.ts b/src/main/window/renderer-launch-failure-backoff.ts new file mode 100644 index 00000000000..f815a0d9597 --- /dev/null +++ b/src/main/window/renderer-launch-failure-backoff.ts @@ -0,0 +1,31 @@ +// Why: a refused renderer spawn (e.g. macOS 1003 at the process limit) lasts minutes, so retry over ~2 min, not the breaker's 750ms. +export const RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS: readonly number[] = [ + 250, 1_000, 2_000, 4_000, 8_000, 15_000, 30_000, 60_000 +] + +export type RendererLaunchFailureBackoff = { + /** Delay before the next retry, or null once the schedule is spent. */ + nextDelayMs: () => number | null + attempts: () => number + reset: () => void +} + +export function createRendererLaunchFailureBackoff( + delaysMs: readonly number[] = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS +): RendererLaunchFailureBackoff { + let attempts = 0 + return { + nextDelayMs: () => { + const delay = delaysMs[attempts] + if (delay === undefined) { + return null + } + attempts += 1 + return delay + }, + attempts: () => attempts, + reset: () => { + attempts = 0 + } + } +} diff --git a/src/main/window/renderer-launch-failure-probe.spawn.test.ts b/src/main/window/renderer-launch-failure-probe.spawn.test.ts new file mode 100644 index 00000000000..aa6c13e609f --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.spawn.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { probeRendererLaunchCapacity } from './renderer-launch-failure-probe' + +describe.skipIf(process.platform === 'win32')('probeRendererLaunchCapacity (real spawn)', () => { + it('reports ok when the host can start a process', async () => { + await expect(probeRendererLaunchCapacity()).resolves.toBe('ok') + }) +}) diff --git a/src/main/window/renderer-launch-failure-probe.test.ts b/src/main/window/renderer-launch-failure-probe.test.ts new file mode 100644 index 00000000000..29714f84dad --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { runProcessMock, recordDurableCrashBreadcrumbMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + recordDurableCrashBreadcrumbMock: vi.fn() +})) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('../crash-reporting/durable-crash-breadcrumb', () => ({ + recordDurableCrashBreadcrumb: recordDurableCrashBreadcrumbMock +})) + +import { withPlatform } from './createMainWindow-test-harness' + +import { + classifyLaunchProbeError, + probeRendererLaunchCapacity, + recordRendererLaunchFailureProbe +} from './renderer-launch-failure-probe' + +function spawnError(code: string): Error { + return Object.assign(new Error(`spawn /bin/sh ${code}`), { code, errno: -35, syscall: 'spawn' }) +} + +function recordPosixFailure(now: number) { + return withPlatform('darwin', () => recordRendererLaunchFailureProbe({ exitCode: 1003 }, now)) +} + +describe('classifyLaunchProbeError', () => { + it.each(['EAGAIN', 'EACCES', 'ENOENT', 'EMFILE'])('reports the spawn errno %s', (code) => { + expect(classifyLaunchProbeError(spawnError(code))).toBe(code) + }) + + it.each([ + ['a plain error', new Error('boom')], + ['a non-errno code', Object.assign(new Error('x'), { code: 'ERR_INVALID_ARG_TYPE' })], + ['a numeric code', Object.assign(new Error('x'), { code: 11 })], + ['a non-error throw', 'EAGAIN'] + ])('reports unknown for %s', (_label, error) => { + expect(classifyLaunchProbeError(error)).toBe('unknown') + }) +}) + +describe('probeRendererLaunchCapacity', () => { + afterEach(() => { + runProcessMock.mockReset() + recordDurableCrashBreadcrumbMock.mockReset() + }) + + it('reports ok for any child that started, whatever its exit', async () => { + runProcessMock.mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + await expect(probeRendererLaunchCapacity('darwin')).resolves.toBe('ok') + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ program: '/bin/sh', args: ['-c', 'exit 0'] }) + ) + }) + + // Windows EDR scores a short-lived child per operation; the breadcrumb still records the launch failure. + it('skips the spawn on Windows', async () => { + await expect(probeRendererLaunchCapacity('win32')).resolves.toBe('skipped') + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('records a skipped Windows probe without spawning a child', async () => { + await expect( + withPlatform('win32', () => recordRendererLaunchFailureProbe({ exitCode: 18 }, 100_000)) + ).resolves.toBe('skipped') + expect(runProcessMock).not.toHaveBeenCalled() + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_launch_failed_probe', { + spawnError: 'skipped', + exitCode: 18 + }) + }) + + it('never rejects when the breadcrumb write throws', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + recordDurableCrashBreadcrumbMock.mockImplementation(() => { + throw new Error('disk full') + }) + await expect(recordPosixFailure(90_000)).resolves.toBe('EAGAIN') + }) + + it('records the refused spawn as a durable breadcrumb', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + await expect(recordPosixFailure(10_000)).resolves.toBe('EAGAIN') + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_launch_failed_probe', { + spawnError: 'EAGAIN', + exitCode: 1003 + }) + }) + + it('probes once for the duplicate render-process-gone of one failed launch', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + await recordPosixFailure(50_000) + await recordPosixFailure(50_010) + expect(runProcessMock).toHaveBeenCalledOnce() + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledOnce() + + // The next backoff retry fails again: a fresh probe and breadcrumb. + await recordPosixFailure(50_250) + expect(runProcessMock).toHaveBeenCalledTimes(2) + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/window/renderer-launch-failure-probe.ts b/src/main/window/renderer-launch-failure-probe.ts new file mode 100644 index 00000000000..fd81b419c6f --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.ts @@ -0,0 +1,69 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' + +/** 'ok' when a throwaway child could start, the spawn errno (EAGAIN = per-user process limit), or 'skipped' on Windows. */ +export type RendererLaunchProbeResult = string + +const PROBE_TIMEOUT_MS = 5_000 +const ERRNO_CODE = /^E[A-Z0-9]{1,15}$/ + +export function classifyLaunchProbeError(error: unknown): RendererLaunchProbeResult { + const code = error instanceof Error && 'code' in error ? error.code : undefined + return typeof code === 'string' && ERRNO_CODE.test(code) ? code : 'unknown' +} + +/** + * Chromium reports any failed helper spawn as launch-failed (macOS exit 1003 = LAUNCH_RESULT_FAILURE), + * so spawn an unrelated child to learn whether the OS is refusing every new process. + */ +export async function probeRendererLaunchCapacity( + platform: NodeJS.Platform = process.platform +): Promise { + // Why skip Windows: a short-lived child per failed launch is the per-operation spawn burst EDR scores (windows-edr-posture.md). + if (platform === 'win32') { + return 'skipped' + } + try { + // Exit status is irrelevant: a child that started proves spawn headroom. /bin/sh is at a fixed path on every POSIX host. + await runProcess({ + program: '/bin/sh', + args: ['-c', 'exit 0'], + timeoutMs: PROBE_TIMEOUT_MS, + maxOutputBytes: 4096 + }) + return 'ok' + } catch (error) { + return classifyLaunchProbeError(error) + } +} + +// Electron emits render-process-gone twice (~10ms apart) for one failed launch; probe and record it once. +const PROBE_COALESCE_MS = 100 +let lastProbe: { startedAt: number; result: Promise } | null = null + +export function recordRendererLaunchFailureProbe( + details: Pick, + now: number = Date.now() +): Promise { + if ( + lastProbe && + now - lastProbe.startedAt >= 0 && + now - lastProbe.startedAt < PROBE_COALESCE_MS + ) { + return lastProbe.result + } + // Never rejects: callers fire-and-forget it from the render-process-gone handler. + const result = probeRendererLaunchCapacity().then((spawnError) => { + try { + recordDurableCrashBreadcrumb('renderer_launch_failed_probe', { + spawnError, + exitCode: details.exitCode ?? null + }) + } catch { + // Diagnostics only. + } + return spawnError + }) + lastProbe = { startedAt: now, result } + return result +} diff --git a/src/main/window/renderer-recovery-prompt.test.ts b/src/main/window/renderer-recovery-prompt.test.ts index 32f6e927b56..2415fadac58 100644 --- a/src/main/window/renderer-recovery-prompt.test.ts +++ b/src/main/window/renderer-recovery-prompt.test.ts @@ -183,4 +183,91 @@ describe('presentRendererRecoveryPrompt', () => { expect(shown[0].detail).toContain('repairing now') expect(shown[1].detail).toContain('repaired') }) + + describe('after a renderer launch failure', () => { + it('names the process limit when the probe is refused with EAGAIN', async () => { + const probeLaunchCapacity = vi.fn(async () => 'EAGAIN') + const { run, shown, reload, quit } = harness({ + failure: 'launch-failed', + recentRecoveryCount: 8, + probeLaunchCapacity + }) + await run() + expect(probeLaunchCapacity).toHaveBeenCalledOnce() + // No Restart: app.relaunch needs a free process slot too and silently fails without one. + expect(shown[0].buttons).toEqual(['Try Again', 'Quit']) + expect(shown[0].defaultId).toBe(0) + expect(shown[0].cancelId).toBe(0) + expect(shown[0].message).toContain("couldn't start the process") + expect(shown[0].detail).toContain('retried 8 times') + expect(shown[0].detail).toContain('process limit') + expect(shown[0].detail).not.toMatch(/graphics|driver/i) + expect(reload).toHaveBeenCalledOnce() + expect(quit).not.toHaveBeenCalled() + }) + + it('drops the graphics-driver blame when the probe could spawn', async () => { + const { run, shown, quit } = harness({ + failure: 'launch-failed', + probeLaunchCapacity: async () => 'ok', + responses: [1] + }) + await run() + expect(shown[0].detail).not.toContain('process limit') + expect(shown[0].detail).not.toMatch(/graphics|driver/i) + expect(shown[0].detail).toContain('Try Again') + expect(quit).toHaveBeenCalledOnce() + }) + + it('probes once even when Copy Commands re-shows the box', async () => { + const probeLaunchCapacity = vi.fn(async () => 'ok') + const { run, shown, reload } = harness({ + failure: 'launch-failed', + diagnose: () => POISON, + probeLaunchCapacity, + responses: [1, 1, 0] + }) + await run() + expect(shown).toHaveLength(3) + expect(probeLaunchCapacity).toHaveBeenCalledOnce() + expect(reload).toHaveBeenCalledOnce() + }) + + it('shows nothing when the app starts quitting during the probe', async () => { + let quitting = false + const { run, shown, reload } = harness({ + failure: 'launch-failed', + isQuitting: () => quitting, + probeLaunchCapacity: async () => { + quitting = true + return 'EAGAIN' + } + }) + await run() + expect(shown).toEqual([]) + expect(reload).not.toHaveBeenCalled() + }) + + it('does not probe once the app is already quitting', async () => { + const probeLaunchCapacity = vi.fn(async () => 'ok') + const { run } = harness({ + failure: 'launch-failed', + isQuitting: () => true, + probeLaunchCapacity + }) + await run() + expect(probeLaunchCapacity).not.toHaveBeenCalled() + }) + + it('keeps the install-permission diagnosis and its copy button', async () => { + const { run, shown } = harness({ + failure: 'launch-failed', + diagnose: () => POISON, + probeLaunchCapacity: async () => 'ok' + }) + await run() + expect(shown[0].buttons).toEqual(['Try Again', 'Copy Commands', 'Quit']) + expect(shown[0].detail).toContain(POISON.detail) + }) + }) }) diff --git a/src/main/window/renderer-recovery-prompt.ts b/src/main/window/renderer-recovery-prompt.ts index bd8b3b56b52..73992b666e6 100644 --- a/src/main/window/renderer-recovery-prompt.ts +++ b/src/main/window/renderer-recovery-prompt.ts @@ -2,6 +2,7 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' import { translateMain } from '../i18n/main-i18n' import type { InstallDirAclPoisonDiagnosis } from '../startup/windows-install-dir-acl-recovery' import type { RecoveryExhaustionCause } from './renderer-recovery-reload-watchdog' +import type { RendererLaunchProbeResult } from './renderer-launch-failure-probe' export type RendererRecoveryPromptFailure = RecoveryExhaustionCause @@ -12,6 +13,8 @@ export type RendererRecoveryPromptDeps = { availableCommitMB?: number isQuitting: () => boolean diagnose: () => InstallDirAclPoisonDiagnosis | null + /** Re-checks spawn headroom so a launch-failed prompt can name the process limit. */ + probeLaunchCapacity?: () => Promise showMessageBox: (options: MessageBoxOptions) => Promise copyToClipboard: (text: string) => void reload: () => void @@ -22,46 +25,31 @@ export async function presentRendererRecoveryPrompt( deps: RendererRecoveryPromptDeps ): Promise { const stalled = deps.failure === 'reload-stalled' + const launchFailed = deps.failure === 'launch-failed' const lowCommit = deps.failure === 'low-commit' + if (deps.isQuitting()) { + return + } + // Probed once: Copy Commands re-shows the box and must not spawn again. The loop re-checks quitting after it. + const launchProbe = launchFailed ? await deps.probeLaunchCapacity?.() : undefined // Copying must preserve the only available recovery surface. while (!deps.isQuitting()) { - // Why skip: the low-commit text replaces diagnosis.detail, which would leave Copy Commands unexplained. const diagnosis = lowCommit ? null : deps.diagnose() - const buttons = [translateMain('rendererRecovery.reload', 'Reload')] + // Why no Restart button: relaunching needs a free process slot too, and app.relaunch fails silently without one. + const buttons = [ + launchFailed + ? translateMain('rendererRecovery.tryAgain', 'Try Again') + : translateMain('rendererRecovery.reload', 'Reload') + ] if (diagnosis) { buttons.push(translateMain('rendererRecovery.copyCommands', 'Copy Commands')) } buttons.push(translateMain('rendererRecovery.quit', 'Quit')) - const recoveryDetail = lowCommit - ? translateMain( - 'rendererRecovery.lowCommitDetail', - 'Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.', - { availableMB: deps.availableCommitMB ?? 0 } - ) - : stalled - ? translateMain( - 'rendererRecovery.stalledDetail', - 'Orca reloaded the window after a crash, but it never finished loading.' - ) - : translateMain( - 'rendererRecovery.crashLoopDetail', - 'Orca tried to recover {{recoveryCount}} times in a row without success.', - { recoveryCount: deps.recentRecoveryCount } - ) - const causeDetail = lowCommit - ? translateMain( - 'rendererRecovery.lowCommitAdvice', - 'Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.' - ) - : diagnosis - ? `${diagnosis.detail}\n\n${translateMain( - 'rendererRecovery.driverFallback', - 'If that does not help, the cause is usually a graphics driver.' - )}` - : translateMain( - 'rendererRecovery.genericDetail', - 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' - ) + const content = launchFailed + ? describeLaunchFailure(deps.recentRecoveryCount, launchProbe, diagnosis) + : lowCommit + ? describeLowCommit(deps.availableCommitMB ?? 0) + : describeRendererCrash(stalled, deps.recentRecoveryCount, diagnosis) const { response } = await deps.showMessageBox({ type: 'error', buttons, @@ -69,18 +57,7 @@ export async function presentRendererRecoveryPrompt( // Escape retries instead of destroying the session. cancelId: 0, title: translateMain('rendererRecovery.title', 'Orca keeps failing to load'), - message: lowCommit - ? translateMain('rendererRecovery.lowCommitMessage', 'Windows is out of memory.') - : stalled - ? translateMain( - 'rendererRecovery.stalledMessage', - 'The app window stopped responding while reloading after a crash.' - ) - : translateMain( - 'rendererRecovery.crashLoopMessage', - 'The app window crashed repeatedly and stopped reloading automatically.' - ), - detail: `${recoveryDetail}\n\n${causeDetail}` + ...content }) if (response === 1 && diagnosis) { deps.copyToClipboard(diagnosis.commands.join('\r\n')) @@ -94,3 +71,87 @@ export async function presentRendererRecoveryPrompt( return } } + +type PromptContent = { message: string; detail: string } + +function describeLowCommit(availableMB: number): PromptContent { + const recoveryDetail = translateMain( + 'rendererRecovery.lowCommitDetail', + 'Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.', + { availableMB } + ) + const advice = translateMain( + 'rendererRecovery.lowCommitAdvice', + 'Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.' + ) + return { + message: translateMain('rendererRecovery.lowCommitMessage', 'Windows is out of memory.'), + detail: `${recoveryDetail}\n\n${advice}` + } +} + +function describeLaunchFailure( + attempts: number, + probe: RendererLaunchProbeResult | undefined, + diagnosis: InstallDirAclPoisonDiagnosis | null +): PromptContent { + const message = translateMain( + 'rendererRecovery.launchFailedMessage', + "Orca couldn't start the process that draws its window." + ) + const retried = translateMain( + 'rendererRecovery.launchFailedDetail', + 'Orca retried {{recoveryCount}} times without success.', + { recoveryCount: attempts } + ) + const cause = + probe === 'EAGAIN' + ? translateMain( + 'rendererRecovery.processLimitDetail', + 'The system refused to create a new process because your user account reached its process limit. This is often caused by runaway terminals, dev servers, or agents. Close some of them, then click Try Again.' + ) + : (diagnosis?.detail ?? + translateMain( + 'rendererRecovery.launchFailedGenericDetail', + 'The system refused to start it. Free up memory or close other apps, then click Try Again. If this keeps happening, reinstall Orca.' + )) + return { message, detail: `${retried}\n\n${cause}` } +} + +function describeRendererCrash( + stalled: boolean, + recoveryCount: number, + diagnosis: InstallDirAclPoisonDiagnosis | null +): PromptContent { + const recoveryDetail = stalled + ? translateMain( + 'rendererRecovery.stalledDetail', + 'Orca reloaded the window after a crash, but it never finished loading.' + ) + : translateMain( + 'rendererRecovery.crashLoopDetail', + 'Orca tried to recover {{recoveryCount}} times in a row without success.', + { recoveryCount } + ) + const causeDetail = diagnosis + ? `${diagnosis.detail}\n\n${translateMain( + 'rendererRecovery.driverFallback', + 'If that does not help, the cause is usually a graphics driver.' + )}` + : translateMain( + 'rendererRecovery.genericDetail', + 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' + ) + return { + message: stalled + ? translateMain( + 'rendererRecovery.stalledMessage', + 'The app window stopped responding while reloading after a crash.' + ) + : translateMain( + 'rendererRecovery.crashLoopMessage', + 'The app window crashed repeatedly and stopped reloading automatically.' + ), + detail: `${recoveryDetail}\n\n${causeDetail}` + } +} diff --git a/src/main/window/renderer-recovery-reload-watchdog.ts b/src/main/window/renderer-recovery-reload-watchdog.ts index f2f013d18e0..4d21da2a2cc 100644 --- a/src/main/window/renderer-recovery-reload-watchdog.ts +++ b/src/main/window/renderer-recovery-reload-watchdog.ts @@ -25,7 +25,11 @@ const MILESTONE_RANK: Record = { 'dom-ready': 2 } -export type RecoveryExhaustionCause = 'crash-loop' | 'reload-stalled' | 'low-commit' +export type RecoveryExhaustionCause = + | 'crash-loop' + | 'reload-stalled' + | 'launch-failed' + | 'low-commit' export type RendererRecoveryReloadWatchdog = { /** Issues a recovery reload and arms the stall watchdog. */ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1fd38350c69..2f29e560803 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18542,7 +18542,12 @@ "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically.", "lowCommitMessage": "Windows is out of memory.", "lowCommitDetail": "Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.", - "lowCommitAdvice": "Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload." + "lowCommitAdvice": "Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.", + "tryAgain": "Try Again", + "launchFailedMessage": "Orca couldn't start the process that draws its window.", + "launchFailedDetail": "Orca retried {{recoveryCount}} times without success.", + "processLimitDetail": "The system refused to create a new process because your user account reached its process limit. This is often caused by runaway terminals, dev servers, or agents. Close some of them, then click Try Again.", + "launchFailedGenericDetail": "The system refused to start it. Free up memory or close other apps, then click Try Again. If this keeps happening, reinstall Orca." }, "notifications": { "agentStatus": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 376d83373bb..85a1b64c154 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18508,7 +18508,12 @@ "genericDetail": "Il s'agit souvent d'un problème de pilote graphique ou d'installation. Rechargez pour réessayer, ou quittez et relancez Orca.", "title": "Orca ne parvient toujours pas à charger", "stalledMessage": "La fenêtre de l'application a cessé de répondre lors du rechargement après un crash.", - "crashLoopMessage": "La fenêtre de l'application s'est écrasée à plusieurs reprises et a cessé de se recharger automatiquement." + "crashLoopMessage": "La fenêtre de l'application s'est écrasée à plusieurs reprises et a cessé de se recharger automatiquement.", + "tryAgain": "Réessayer", + "launchFailedMessage": "Orca n'a pas pu démarrer le processus qui affiche sa fenêtre.", + "launchFailedDetail": "Orca a réessayé {{recoveryCount}} fois sans succès.", + "processLimitDetail": "Le système a refusé de créer un nouveau processus, car votre compte utilisateur a atteint sa limite de processus. Cela vient souvent de terminaux, de serveurs de développement ou d'agents emballés. Fermez-en quelques-uns, puis cliquez sur Réessayer.", + "launchFailedGenericDetail": "Le système a refusé de le démarrer. Libérez de la mémoire ou fermez d'autres applications, puis cliquez sur Réessayer. Si le problème persiste, réinstallez Orca." }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 84cc645254d..0bed9bab422 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18508,7 +18508,12 @@ "genericDetail": "これは多くの場合、グラフィックスドライバーまたはインストールの問題です。リロードして再試行するか、Orca を終了して再起動してください。", "title": "Orca がロードに失敗し続ける", "stalledMessage": "クラッシュ後のリロード中にアプリウィンドウが応答を停止しました。", - "crashLoopMessage": "アプリウィンドウが繰り返しクラッシュし、自動的にリロードされなくなりました。" + "crashLoopMessage": "アプリウィンドウが繰り返しクラッシュし、自動的にリロードされなくなりました。", + "tryAgain": "再試行", + "launchFailedMessage": "Orca はウィンドウを描画するプロセスを起動できませんでした。", + "launchFailedDetail": "Orca は {{recoveryCount}} 回再試行しましたが、成功しませんでした。", + "processLimitDetail": "ユーザーアカウントのプロセス数が上限に達したため、システムが新しいプロセスの作成を拒否しました。暴走したターミナル、開発サーバー、Agent が原因であることがよくあります。いくつか終了してから「再試行」をクリックしてください。", + "launchFailedGenericDetail": "システムが起動を拒否しました。メモリを空けるか他のアプリを終了してから「再試行」をクリックしてください。繰り返し発生する場合は Orca を再インストールしてください。" }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1e340fdc837..a503c3347d8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18508,7 +18508,12 @@ "genericDetail": "이는 종종 그래픽 드라이버 또는 설치 문제입니다. 새로고침하여 다시 시도하거나 Orca를 종료하고 다시 시작하세요.", "title": "Orca가 계속 로드되지 않습니다.", "stalledMessage": "충돌 후 다시 로드하는 동안 앱 창이 응답을 멈췄습니다.", - "crashLoopMessage": "앱 창이 반복적으로 충돌하고 자동으로 다시 로드되지 않았습니다." + "crashLoopMessage": "앱 창이 반복적으로 충돌하고 자동으로 다시 로드되지 않았습니다.", + "tryAgain": "다시 시도", + "launchFailedMessage": "Orca가 창을 그리는 프로세스를 시작하지 못했습니다.", + "launchFailedDetail": "Orca가 {{recoveryCount}}번 다시 시도했지만 성공하지 못했습니다.", + "processLimitDetail": "사용자 계정이 프로세스 수 한도에 도달하여 시스템이 새 프로세스 생성을 거부했습니다. 폭주한 터미널, 개발 서버 또는 에이전트가 원인인 경우가 많습니다. 일부를 종료한 다음 다시 시도를 클릭하세요.", + "launchFailedGenericDetail": "시스템이 시작을 거부했습니다. 메모리를 확보하거나 다른 앱을 종료한 다음 다시 시도를 클릭하세요. 계속 발생하면 Orca를 다시 설치하세요." }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b0b9759f4fa..63fc2b87de5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18508,7 +18508,12 @@ "genericDetail": "这通常是图形驱动程序或安装问题。重新加载以重试,或退出并重新启动 Orca。", "title": "Orca 一直无法加载", "stalledMessage": "崩溃后重新加载时应用程序窗口停止响应。", - "crashLoopMessage": "应用程序窗口反复崩溃并自动停止重新加载。" + "crashLoopMessage": "应用程序窗口反复崩溃并自动停止重新加载。", + "tryAgain": "重试", + "launchFailedMessage": "Orca 无法启动绘制其窗口的进程。", + "launchFailedDetail": "Orca 重试了 {{recoveryCount}} 次,但未成功。", + "processLimitDetail": "由于你的用户帐户已达到进程数上限,系统拒绝创建新进程。这通常由失控的终端、开发服务器或代理导致。请关闭其中一些,然后点击“重试”。", + "launchFailedGenericDetail": "系统拒绝启动它。请释放内存或关闭其他应用,然后点击“重试”。如果问题持续出现,请重新安装 Orca。" }, "sessionSearch": { "panel": { diff --git a/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts index 5c922608008..c72ea255e98 100644 --- a/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts +++ b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts @@ -1,18 +1,23 @@ import { afterEach, expect, it, vi } from 'vitest' import { setTerminalPtyWriteDelay } from './terminal-pty-write-spy' +declare global { + var __terminalPtyWriteDelayMs: number | undefined +} + afterEach(() => vi.unstubAllGlobals()) it('sets and clears paste backpressure using the main-process evaluate argument', async () => { vi.stubGlobal('__terminalPtyWriteDelayMs', 0) const evaluate = vi.fn() evaluate.mockImplementation( - (callback: (electron: object, argument: number) => void, delay: number) => callback({}, delay) + (callback: (electron: unknown, argument: number) => void, delay: number) => + callback(undefined, delay) ) const app = { evaluate } await setTerminalPtyWriteDelay(app, 35) - expect(Reflect.get(globalThis, '__terminalPtyWriteDelayMs')).toBe(35) + expect(globalThis.__terminalPtyWriteDelayMs).toBe(35) await setTerminalPtyWriteDelay(app, 0) - expect(Reflect.get(globalThis, '__terminalPtyWriteDelayMs')).toBe(0) + expect(globalThis.__terminalPtyWriteDelayMs).toBe(0) }) From 66799f7e8f06fd09c432932586cea460801919f6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:34:40 -0700 Subject: [PATCH 26/26] Keep paired browser terminal insertion in the host's requested position (#24676) * test: align source-control fixtures with current store contracts * Bound E2E package setup and retain cancelled-job traces * Remove empty passing sentinels from opt-in socket tests * Make SSH typing pressure fixture readiness and replies observable * Advertise browser support for anchored terminal placement --- .../src/web/web-runtime-client.test.ts | 2 + .../web-runtime-connection-frame-router.ts | 2 + ...ed-terminal-after-anchor-placement.spec.ts | 150 ++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 tests/e2e/paired-terminal-after-anchor-placement.spec.ts diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index cd290606fb2..787e130821d 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -16,6 +16,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -94,6 +95,7 @@ describe('WebRuntimeClient', () => { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index 626b49f5612..78dbf6f0553 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -5,6 +5,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -66,6 +67,7 @@ export async function routeWebRuntimeConnectionFrame( AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/tests/e2e/paired-terminal-after-anchor-placement.spec.ts b/tests/e2e/paired-terminal-after-anchor-placement.spec.ts new file mode 100644 index 00000000000..8583b58c587 --- /dev/null +++ b/tests/e2e/paired-terminal-after-anchor-placement.spec.ts @@ -0,0 +1,150 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { toHostSessionTabId, toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' + +async function createTerminal(page: Page, worktreeId: string, afterTabId?: string) { + const tabs = page.locator('[data-testid="sortable-tab"]') + const before = await tabs.evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute('data-tab-id')) + ) + const outcome = await page.evaluate( + async ({ worktreeId, afterTabId }) => { + const environment = (await window.api.runtimeEnvironments.list())[0] + const bridge: unknown = + '__webRuntimeSessionE2E' in window ? window.__webRuntimeSessionE2E : undefined + if ( + !environment || + !bridge || + typeof bridge !== 'object' || + !('createTerminal' in bridge) || + typeof bridge.createTerminal !== 'function' + ) { + throw new Error('Paired terminal creation is unavailable') + } + return bridge.createTerminal({ + worktreeId, + environmentId: environment.id, + activate: false, + ...(afterTabId ? { afterTabId } : {}) + }) + }, + { worktreeId, afterTabId } + ) + expect(outcome).toEqual({ status: 'created' }) + let created: string[] = [] + await expect + .poll(async () => { + const ids = await tabs.evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute('data-tab-id')) + ) + created = ids.filter((id): id is string => id !== null && !before.includes(id)) + return created.length + }) + .toBe(1) + const parentTabId = toHostSessionTabId(created[0]) + const surfaceId = await page.evaluate( + async ({ worktreeId, parentTabId }) => { + const response = await window.api.runtime.call({ + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = response.result + if ( + !result || + typeof result !== 'object' || + !('tabs' in result) || + !Array.isArray(result.tabs) + ) { + throw new Error('Terminal inventory is missing') + } + const surface: unknown = result.tabs.find((tab) => tab.parentTabId === parentTabId) + if ( + !surface || + typeof surface !== 'object' || + !('id' in surface) || + typeof surface.id !== 'string' + ) { + throw new Error('Created terminal surface is missing') + } + return surface.id + }, + { worktreeId, parentTabId } + ) + return { id: toWebTerminalSurfaceTabId(surfaceId), parentTabId } +} + +async function tabWindow(page: Page, anchor: string) { + return page.locator('[data-testid="sortable-tab"]').evaluateAll((tabs, anchor) => { + const ids = tabs.map((tab) => tab.getAttribute('data-tab-id')) + const index = ids.indexOf(anchor) + return index === -1 ? [] : ids.slice(index, index + 3) + }, anchor) +} + +test('paired web creation places a terminal after its anchor on both host and client', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + try { + const worktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('Paired worktree is missing') + } + await client.page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + await expect( + client.page.locator('[data-testid="sortable-tab"][data-active="true"]') + ).toBeVisible() + const first = await createTerminal(client.page, worktreeId) + const second = await createTerminal(client.page, worktreeId) + const inserted = await createTerminal(client.page, worktreeId, first.id) + const labels = [first, second, inserted].map((tab, index) => ({ + id: tab.parentTabId, + webId: toWebTerminalSurfaceTabId(tab.parentTabId), + title: ['Anchor A', 'Successor B', 'Inserted C'][index] + })) + for (const [page, web] of [ + [orcaPage, false], + [client.page, true] + ] as const) { + await page.evaluate( + ({ labels, web }) => { + for (const { id, webId, title } of labels) { + window.__store?.getState().setTabCustomTitle(web ? webId : id, title) + } + }, + { labels, web } + ) + } + await expect + .poll(() => tabWindow(orcaPage, first.parentTabId), { timeout: 15_000 }) + .toEqual([first.parentTabId, inserted.parentTabId, second.parentTabId]) + const clientIds = [first, inserted, second].map((tab) => + toWebTerminalSurfaceTabId(tab.parentTabId) + ) + await expect + .poll(() => tabWindow(client.page, clientIds[0]), { timeout: 15_000 }) + .toEqual(clientIds) + } finally { + await testInfo.attach('host-tab-placement', { + body: await orcaPage.screenshot({ path: testInfo.outputPath('host-tab-placement.png') }), + contentType: 'image/png' + }) + await testInfo.attach('client-tab-placement', { + body: await client.page.screenshot({ path: testInfo.outputPath('client-tab-placement.png') }), + contentType: 'image/png' + }) + await client.dispose() + } +})