mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge main and focus server deletion repair on native background ownership
This commit is contained in:
@@ -138,7 +138,6 @@ export type ComputerActionFollowUpTarget = {
|
||||
worktree?: string
|
||||
windowId?: number
|
||||
windowIndex?: number
|
||||
restoreWindow?: boolean
|
||||
}
|
||||
|
||||
export function formatComputerAction(
|
||||
@@ -190,9 +189,6 @@ function formatComputerFollowUpCommand(
|
||||
args.push('--window-index', String(windowIndex))
|
||||
}
|
||||
}
|
||||
if (target.restoreWindow) {
|
||||
args.push('--restore-window')
|
||||
}
|
||||
return args.join(' ')
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@ export const FLAG_HELP_TEXT: Record<string, string> = {
|
||||
query: '--query <text> Search text for matching refs',
|
||||
ref: '--ref <ref> Base ref to persist for the repo',
|
||||
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
|
||||
'restore-window': '--restore-window Bring the target app/window forward before the operation',
|
||||
'restore-window':
|
||||
"--restore-window Bring the target window forward and use foreground input; takes over the user's screen, so ask the user first",
|
||||
session: '--session <id> Snapshot namespace for a related computer-use workflow',
|
||||
setup: '--setup run|skip|inherit Setup policy for repo-defined setup hooks',
|
||||
surface:
|
||||
|
||||
@@ -88,7 +88,7 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
|
||||
if (hasOrchestrationRequestId(error.data)) {
|
||||
return message
|
||||
}
|
||||
return formatRuntimeUnavailableError(message, error.data)
|
||||
return formatRuntimeUnavailableError(message, error)
|
||||
}
|
||||
// Match before generic recovery; flattened errors may retain only the conflict token.
|
||||
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
|
||||
@@ -111,12 +111,6 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
error instanceof RuntimeRpcFailureError &&
|
||||
error.response.error.code === 'runtime_unavailable'
|
||||
) {
|
||||
return formatRuntimeUnavailableError(message, error.response.error.data)
|
||||
}
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data))
|
||||
}
|
||||
@@ -131,11 +125,14 @@ function hasOrchestrationRequestId(data: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function formatRuntimeUnavailableError(message: string, data: unknown): string {
|
||||
const nextSteps = nextStepsFromData(data)
|
||||
function formatRuntimeUnavailableError(message: string, error: RuntimeClientError): string {
|
||||
const nextSteps = nextStepsFromData(error.data)
|
||||
if (nextSteps.length > 0) {
|
||||
return formatMessageWithNextSteps(message, nextSteps)
|
||||
}
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return `${message}\nOrca is running but not ready for this yet. Wait a few seconds and retry; do not run 'orca open'. If it keeps failing and 'orca status --json' shows app.desktopWindowStatus "openable", Orca's window is closed: ask the user to reopen it.`
|
||||
}
|
||||
return `${message}\nOrca is not running. Run 'orca open' first.`
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ import { runtimeAccessDeniedError } from './runtime-access-denied'
|
||||
import { MAX_LOCAL_RUNTIME_RPC_MESSAGE_BYTES } from '../../shared/local-runtime-rpc-limits'
|
||||
import { daemonSocketDirIsTrusted } from '../../main/daemon/endpoint/publication/daemon-socket-endpoint-path'
|
||||
|
||||
// Agents read these; a blip must not send them to `orca open`, which raises the window.
|
||||
const TRANSIENT_DISCONNECT_NEXT_STEPS = [
|
||||
'Orca is usually busy or restarting: wait a few seconds, then retry. If the command changes something, first check whether it already took effect, and retry with the request ID this error names, if any.',
|
||||
"Do not run 'orca open' to recover. Run it only if the user asks, or if 'orca status --json' reports Orca is not running or still cannot connect after about 30 seconds of retries."
|
||||
]
|
||||
|
||||
export async function sendRequest<TResult>(
|
||||
metadata: RuntimeMetadata,
|
||||
method: string,
|
||||
@@ -104,7 +110,7 @@ export async function sendRequest<TResult>(
|
||||
new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'This terminal could not connect to Orca.',
|
||||
{ nextSteps: ['Check that Orca is open and that this terminal can access it.'] }
|
||||
{ nextSteps: TRANSIENT_DISCONNECT_NEXT_STEPS }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -115,7 +121,7 @@ export async function sendRequest<TResult>(
|
||||
error: new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'Orca closed the connection before responding.',
|
||||
{ nextSteps: ['Check that Orca is open and that this terminal can access it.'] }
|
||||
{ nextSteps: TRANSIENT_DISCONNECT_NEXT_STEPS }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -393,15 +393,14 @@ const ORCA_CLI_MARKDOWN = [
|
||||
"ORCA worktree create --name task --parent-worktree active --agent codex --prompt \"hi\" --json",
|
||||
"ORCA worktree create --name task --parent-worktree active --agent claude --setup run --json",
|
||||
"ORCA worktree create --name task --parent-worktree active --setup skip --json",
|
||||
"ORCA worktree create --name task --parent-worktree active --run-hooks --json",
|
||||
"```",
|
||||
"",
|
||||
"- `--agent <id>` starts that agent with the worktree, as a chat or a terminal per the user's Chat | CLI choice for that agent (see Starting agents); `--prompt <text>` sends initial work to it, and `--model <id>` / `--effort <level>` pick its model and reasoning effort, and `--surface chat|terminal` picks chat or terminal for this launch. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed agents.",
|
||||
"- Repo setup or default-terminal settings may still add tabs or splits. Configured default tabs are intentional; never close one without verifying it is an unused shell.",
|
||||
"- Address the agent through exactly one handle: `launch.outcome.handle`. Handles are runtime-scoped: after an Orca restart or a `terminal_handle_stale` error, re-list and continue with the replacement only; never dual-send to old and replacement handles. `--agent` already started the agent, so do not start it again.",
|
||||
"- `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy.",
|
||||
"- `--run-hooks` is a legacy alias for `--setup run`; it also reveals/activates the new worktree.",
|
||||
"- `--activate` and `--run-hooks` reveal the new worktree. `--agent` alone stays in the background.",
|
||||
"- `--run-hooks` is a legacy alias for `--setup run` that also reveals the new worktree; use `--setup run` instead.",
|
||||
"- `--activate` and `--run-hooks` switch the user's view to the new worktree, so pass them only when the user asked to switch to it. `--agent` alone stays in the background.",
|
||||
"- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior.",
|
||||
"- `worktree create` makes a new checkout. For a fresh agent in the **current** checkout, use `ORCA agent launch --agent codex --json`.",
|
||||
"",
|
||||
@@ -488,6 +487,7 @@ const ORCA_CLI_MARKDOWN = [
|
||||
"- A chat tab has no terminal handle, so `terminal rename` cannot rename it. Use `ORCA chat rename --session <sessionId> --name \"New Name\" --json` with the `sessionId` `agent launch` printed; inside a chat, omit `--session` to rename that chat.",
|
||||
"- `terminal create --title` and `terminal rename` set the tab title, which `terminal list` reports under `visualLayouts`. `terminal set-pane-title` sets one pane's title, reported as `terminals[].paneTitle`. `terminals[].title` may be the tab title or the running program's own title, which agents repaint constantly, so never match on it to re-find a terminal.",
|
||||
"- `terminals[].agentIdentity`, when present, is only the agent type (e.g. `codex`), not a session identity, so two Codex panes share it. To find a specific agent's terminal, join `worktree ps --json` `agents[].paneKey` (`<tabId>:<leafId>`) to the `tabId` and `leafId` of a `terminal list` row to get its handle.",
|
||||
"- `terminal switch` changes the terminal the user is looking at, so run it only when the user asked to switch. Commands that act on one terminal take `--terminal <handle>`, so they never need a switch.",
|
||||
"- A paired server has no window. `terminal switch --environment <name>` moves the desktop app on this machine to that server's terminal, and reports `navigated: false` when no desktop here shows that server.",
|
||||
"- Use `terminal read` before `terminal send` unless the next input is obvious.",
|
||||
"- `terminal read` returns accumulated output with escape sequences stripped, not the rendered screen, so anything that repaints (shells, progress bars, TUIs) reads as stacked fragments. Use `--screen` whenever the answer depends on what the terminal actually shows.",
|
||||
@@ -1022,17 +1022,18 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
|
||||
"---",
|
||||
"name: orca-computer-use",
|
||||
"description: >-",
|
||||
" Drives the GUI of a visible local app window through `orca computer`: accessibility",
|
||||
" tree, clicks, typing, menus, dialogs, and screenshots in native apps and external",
|
||||
" browser windows (Chrome, Edge, Safari) or webviews. Prefer a programmatic path",
|
||||
" (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task.",
|
||||
" Use only when a visible window needs GUI control those cannot reach. Do not use",
|
||||
" for Orca's embedded browser (`orca-cli`).",
|
||||
" Drives the GUI of a local app window in the background through `orca computer`,",
|
||||
" without bringing it forward or taking the user's keyboard and pointer where the",
|
||||
" platform allows: accessibility tree, clicks, typing, menus, dialogs, and screenshots",
|
||||
" in native apps and external browser windows (Chrome, Edge, Safari) or webviews.",
|
||||
" Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever",
|
||||
" it can complete the task. Use only when an app window needs GUI control those",
|
||||
" cannot reach. Do not use for Orca's embedded browser (`orca-cli`).",
|
||||
"---",
|
||||
"",
|
||||
"# Orca Computer Use",
|
||||
"",
|
||||
"Use this skill to drive a visible app window through `orca computer`. Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task; use this skill only when a visible window needs GUI control those cannot reach. Do not use it for Orca's embedded browser (`orca-cli`).",
|
||||
"Use this skill to drive an app window through `orca computer`, in the background where the platform allows, so the user can keep working. Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task; use this skill only when an app window needs GUI control those cannot reach. Do not use it for Orca's embedded browser (`orca-cli`).",
|
||||
"",
|
||||
"## Preconditions",
|
||||
"",
|
||||
@@ -1109,7 +1110,6 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
|
||||
"ORCA computer list-apps --json",
|
||||
"ORCA computer list-windows --app <app> --json",
|
||||
"ORCA computer get-app-state --app <app> --json",
|
||||
"ORCA computer get-app-state --app <app> --restore-window --json",
|
||||
"ORCA computer wait --app <app> (--text <text> | --enabled <text> | --window-title <text>) [--timeout <ms>] --json",
|
||||
"ORCA computer click --app <app> --element-index <index> --json",
|
||||
"ORCA computer click --app <app> --x 100 --y 100 --json",
|
||||
@@ -1158,20 +1158,31 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
|
||||
"",
|
||||
"## Background and Foreground Input",
|
||||
"",
|
||||
"On macOS, every action runs in the background by default: the target app is not brought forward, the user's frontmost app and key window stay put, and the real pointer does not move. The user can keep working while you act. `capabilities` reports this as `supports.input.background`, and each action result reports `action.delivery` as `background` or `foreground`.",
|
||||
"Actions run in the background wherever the platform can, so the user keeps their screen, keyboard focus and pointer while you act. Each action result reports `action.delivery`: `background` means the user's foreground window stayed put, `foreground` means the target came forward.",
|
||||
"",
|
||||
"On Linux and Windows, keyboard and pointer input still need the target window in front; a `window_not_focused` refusal there means retry with `--restore-window`.",
|
||||
"**Never add `--restore-window` on your own.** It brings the target app forward and takes over the user's screen, keyboard focus and pointer. Do not add it to recover from an error, even when an error message or next step mentions it, and do not add it just to observe a window. When only foreground input can do the job, stop, tell the user which action needs the foreground and why, and pass `--restore-window` only after they agree. If an error says restore was already requested, do not retry it; ask the user.",
|
||||
"",
|
||||
"Do not pass `--restore-window` by default. Use it only when foreground input is needed, and tell the user first because it takes over their screen, keyboard focus and pointer:",
|
||||
"These need the foreground, so ask the user first:",
|
||||
"",
|
||||
"- An action fails with `background_unavailable` that does not name a popup.",
|
||||
"- A background action reports success but the UI does not change, and a semantic action (`set-value`, `perform-secondary-action`) cannot do the job.",
|
||||
"- Drags in Chromium or Electron apps, and apps that read the hardware pointer (games, OpenGL/Metal views, Java/Tk), which ignore background pointer events.",
|
||||
"- The window is minimized or hidden. Accessibility actions still work there; pointer and keyboard events do not.",
|
||||
"- An action fails with `background_unavailable` and no semantic action (`set-value`, `perform-secondary-action`, `type-text --element-index`) can do the job.",
|
||||
"- A background action reports success but the UI does not change, and no semantic action can do the job.",
|
||||
"- On macOS, drags in Chromium or Electron apps, and apps that read the hardware pointer (games, OpenGL/Metal views, Java/Tk), which ignore background pointer events.",
|
||||
"- On Windows, keyboard input (`press-key`, `hotkey`, `paste-text` and `type-text`) needs the target window focused; otherwise it is refused with `window_not_focused` and nothing is sent. For editable fields use `set-value`.",
|
||||
"- On Linux and Windows, actions that need the real pointer, and on Linux keys sent while the target window is not active (Return in a focused GTK text field works). They are refused with `background_unavailable`; see below.",
|
||||
"",
|
||||
"Popup menus and web `<select>` controls: set the option with `set-value` on the popup element. Background clicks on them are refused because they open the menu off screen or wedge the app's input.",
|
||||
"On macOS, a window that is minimized, hidden (Cmd+H) or on another Space cannot be read, so `get-app-state` fails with `window_not_found`. Ask the user to bring it on screen.",
|
||||
"",
|
||||
"On Linux, clicks, scrolling, drags and keyboard input still bring the target window forward. On Windows, these go through UI Automation when the control supports them, without moving the pointer or sending keys: element clicks, `set-value` (including sliders), element scrolls by whole `--pages`, and secondary actions such as `Invoke`, `Toggle`, `Select`, `Expand`, `Collapse` and `ScrollToVisible` (the last brings a list or tree item into view). Some apps bring their own window forward when driven this way (WPF apps do for most of these), so read `action.delivery`: `background` means the user's foreground window stayed put, `foreground` means the target came forward. Everything else brings the window forward and reports `foreground`.",
|
||||
"On macOS, background actions that would open a menu or dialog over the user are refused before anything is sent: right-clicks and Control-clicks, clicks or drags that start on popup menus, menu buttons, native combo boxes and web `<select>` controls, and buttons whose title ends in an ellipsis (`Save…`), which usually open a dialog. For a popup, native combo box or `<select>`, set the option with `set-value` on that element.",
|
||||
"",
|
||||
"On macOS, every action without `--restore-window` runs in the background: the target app is not brought forward, the user's frontmost app and key window stay put, and the real pointer does not move. `capabilities` reports this as `supports.input.background`.",
|
||||
"",
|
||||
"On Linux, element clicks through the element's accessibility action, `set-value`, `type-text --element-index` and secondary actions run without raising the window or moving the pointer, and Return in a focused GTK text field runs its activate action. Anything that needs the real pointer (clicks with no accessibility action, right, middle, multiple or modified clicks, scrolling, drags) or the focused window (other keys, typing with no focused editable field) is refused with `background_unavailable` and nothing is sent; so is a click that would open a menu, a keyboard-grabbing popup or a dialog (a GTK 4 or Qt label ending in `…`). Ask the user before retrying with `--restore-window`, which raises only the target window, refuses a window on another workspace and puts the cursor back. On Wayland, synthetic pointer and key input never reaches native windows, so it is refused even with the flag. `action.delivery` is measured: `foreground` when the input went to the window in front or the target came forward.",
|
||||
"",
|
||||
"On Windows, these actions go through UI Automation when the control supports them, without moving the pointer or sending keys: element clicks, `set-value` (including sliders), scrolls of an element or of the scrollable area under `--x`/`--y` (fractional `--pages` too), and secondary actions such as `Invoke`, `Toggle`, `Select`, `Expand`, `Collapse` and `ScrollToVisible` (the last brings a list or tree item into view). Some apps bring their own window forward when driven this way (WPF apps do for most of these), so read `action.delivery`: `background` means the user's foreground window stayed put, `foreground` means the target came forward. Clicks by coordinates, right, middle, modified and multi-clicks, clicks on elements with no `Invoke`, `Select` or `Toggle` action, scrolls over nothing scrollable, and drags need the real mouse, so they are refused with `background_unavailable` and nothing is sent. With `--restore-window` (after asking the user), only the target window comes forward, never one on another virtual desktop, and the pointer goes back where it was.",
|
||||
"",
|
||||
"## Starting Apps",
|
||||
"",
|
||||
"`orca computer` does not launch apps. On macOS, start one without bringing it forward with `open -g -a <App>` (or `open -g -b <bundle-id>`); plain `open -a` brings it to the front. Some apps, such as many Electron apps, still bring their own window forward when it opens. On Windows and Linux a newly started app usually comes to the front, so ask the user before starting one.",
|
||||
"",
|
||||
"## Screenshots",
|
||||
"",
|
||||
@@ -1191,11 +1202,11 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
|
||||
"",
|
||||
"Prefer element indexes or element frames from the tree when available. Use raw screenshot-derived coordinates only after checking the latest screenshot scale and window size.",
|
||||
"",
|
||||
"On Linux and Windows, screenshots may come from the visible desktop region for the target window bounds. If visual pixels matter, use `--restore-window` so another window does not cover the target region; if you cannot take focus, trust the tree over potentially occluded pixels.",
|
||||
"On Linux, screenshots on X11, Hyprland and Sway copy the screen region under the target window, so a window covering it shows in the image; KDE returns a screenshot error instead. On Windows, screenshots come from the window itself even when it is covered, unless the window renders blank that way; then they fall back to the screen region, which can show what covers it. Trust the tree over pixels that may be covered; never add `--restore-window` just to get clean pixels.",
|
||||
"",
|
||||
"## App Notes",
|
||||
"",
|
||||
"Browsers: for Edge, Chrome, Safari, and similar browser windows, set the address/search field directly, then press Return. Do not assume raw typing went to the address bar. On Linux and Windows, use `--restore-window` when the browser is not already frontmost; on macOS this works in the background. Large tab strips may show only the active tab plus an \"inactive browser tabs omitted\" marker; treat that as intentional noise reduction and operate on the current page/address bar unless the user asked to manage tabs.",
|
||||
"Browsers: for Edge, Chrome, Safari, and similar browser windows, set the address/search field directly, then press Return. Do not assume raw typing went to the address bar. On Linux and Windows, pressing Return needs the browser window focused (see Background and Foreground Input); on macOS it works in the background. Large tab strips may show only the active tab plus an \"inactive browser tabs omitted\" marker; treat that as intentional noise reduction and operate on the current page/address bar unless the user asked to manage tabs.",
|
||||
"",
|
||||
"For browser-hosted forms such as Gmail compose, verify the focused UI element after each field action. Page text fields can expose accessibility actions without moving DOM focus; if a click or `set-value` does not change the focused receiver, use `Tab` / `Shift+Tab` from a known focused field or window-local coordinates from a fresh screenshot. Prefer `paste-text` into the verified focused field for draft bodies, then inspect the returned state before continuing.",
|
||||
"",
|
||||
@@ -1214,9 +1225,9 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
|
||||
"- `app_not_found`: run `list-apps` and retry with the bundle ID. If the target is a web app such as Gmail, choose the desktop browser app/window that contains it; do not retry `ORCA computer ... --app Gmail` unchanged because `orca computer` app selectors refer to desktop apps, not website names.",
|
||||
"- `app_blocked`: stop; the target is intentionally blocked from computer-use.",
|
||||
"- `system_shortcut_blocked`: the error message says what the shortcut would do. Reach the same goal with a semantic action, such as a button or menu item, or ask the user; retry with `--allow-system-shortcut` only when the user asked for that exact shortcut.",
|
||||
"- `window_not_found` / `window_stale`: run `list-windows`, choose a current selector, then rerun `get-app-state`.",
|
||||
"- `background_unavailable`: for a popup or `<select>`, use `set-value` on it; otherwise retry once with `--restore-window` after telling the user it will take over their screen.",
|
||||
"- `window_not_focused`: retry once with `--restore-window`; if the message says restore was already requested, stop retrying restore and bring the app forward manually or check permissions. For editable fields prefer `set-value`, then inspect before assuming keyboard input worked.",
|
||||
"- `window_not_found` / `window_stale`: run `list-windows`, choose a current selector, then rerun `get-app-state`. If the app has no usable window, ask the user to open or show one; do not add `--restore-window` to observe it.",
|
||||
"- `background_unavailable`: nothing was sent. For a popup or `<select>`, use `set-value` on it. Otherwise use a semantic action if one fits; if only foreground input can do it, tell the user and ask before retrying with `--restore-window`.",
|
||||
"- `window_not_focused`: unless the message says input may already have been delivered (then inspect state first), nothing was sent. For editable fields use `set-value` or `type-text --element-index`; otherwise ask the user before retrying with `--restore-window`. If the message says restore was already requested, do not retry it; ask the user to bring the window forward or check permissions.",
|
||||
"- `element_not_found`: index is stale; run `get-app-state` again.",
|
||||
"- `unsupported_capability`: the provider or desktop environment cannot do that action; use a semantic alternative or install the missing dependency if the message names one.",
|
||||
"- `action_not_supported`: inspect the element's listed actions and retry with one of those names, or use click/set-value when appropriate.",
|
||||
@@ -4373,7 +4384,7 @@ export const BUNDLED_SKILL_GUIDES = [
|
||||
},
|
||||
{
|
||||
name: "orca-computer-use",
|
||||
description: "Drives the GUI of a visible local app window through `orca computer`: accessibility tree, clicks, typing, menus, dialogs, and screenshots in native apps and external browser windows (Chrome, Edge, Safari) or webviews. Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task. Use only when a visible window needs GUI control those cannot reach. Do not use for Orca's embedded browser (`orca-cli`).",
|
||||
description: "Drives the GUI of a local app window in the background through `orca computer`, without bringing it forward or taking the user's keyboard and pointer where the platform allows: accessibility tree, clicks, typing, menus, dialogs, and screenshots in native apps and external browser windows (Chrome, Edge, Safari) or webviews. Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task. Use only when an app window needs GUI control those cannot reach. Do not use for Orca's embedded browser (`orca-cli`).",
|
||||
markdown: ORCA_COMPUTER_USE_MARKDOWN,
|
||||
aliases: ["computer-use"],
|
||||
references: []
|
||||
|
||||
@@ -15,6 +15,9 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
summary: 'Launch Orca and wait for the runtime to be reachable',
|
||||
usage: 'orca open [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
notes: [
|
||||
"It can bring Orca's window to the front. Agents should run it only when the user asks or `orca status --json` reports Orca is not running or still cannot connect after about 30 seconds of retries, never to retry a failed command."
|
||||
],
|
||||
examples: ['orca open', 'orca open --json']
|
||||
},
|
||||
...SERVE_COMMAND_SPECS,
|
||||
@@ -202,6 +205,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
usage: 'orca terminal switch [--terminal <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal'],
|
||||
notes: [
|
||||
'It changes the terminal the user is looking at; agents should run it only when the user asked to switch.',
|
||||
"With --environment, the desktop app on this machine switches to that server's terminal; a server has no window of its own."
|
||||
],
|
||||
examples: ['orca terminal switch --terminal term_abc123']
|
||||
|
||||
@@ -56,7 +56,7 @@ export const WORKTREE_CREATE_COMMAND_SPEC: CommandSpec = {
|
||||
'With --agent --json, result.launch says what started: launch.outcome.kind (structured chat or terminal), launch.outcome.handle, and launch.receipt.detail for why. An Orca host older than this returns the terminal agent handle in result.agentTerminalHandle instead.',
|
||||
'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.',
|
||||
'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.',
|
||||
'Passing --run-hooks is kept as a legacy alias for --setup run and reveals the worktree.',
|
||||
'Passing --run-hooks is kept as a legacy alias for --setup run and reveals the worktree; agents should pass --setup run instead unless the user asked to switch to it.',
|
||||
'Use --pr for GitHub pull requests; --gitlab-issue and --gitlab-mr write separate GitLab links. GitLab URLs must match the stored source project or remote; they cannot select a foreign project.'
|
||||
],
|
||||
examples: [
|
||||
|
||||
@@ -100,7 +100,8 @@ export async function openAgentLaunch(
|
||||
}
|
||||
|
||||
const launch =
|
||||
intent.target.kind === 'create-worktree' && preflight.mode === 'structured'
|
||||
(intent.target.kind === 'create-worktree' && preflight.mode === 'structured') ||
|
||||
intent.target.kind === 'create-folder-workspace'
|
||||
? beginCreatedWorkspaceLaunch(runtime, intent.agent)
|
||||
: undefined
|
||||
let placed: Awaited<ReturnType<typeof resolveWorkspace>> | undefined
|
||||
@@ -233,14 +234,14 @@ async function resolveWorkspace(
|
||||
throw new Error('agent_launch_workspace_factory_required')
|
||||
}
|
||||
execution.onStage?.('worktree_create')
|
||||
if (intent.target.kind === 'create-folder-workspace') {
|
||||
if (!workspaces.createFolderWorkspace) {
|
||||
throw new Error('agent_launch_workspace_factory_required')
|
||||
}
|
||||
const created = await workspaces.createFolderWorkspace({ create: intent.target.create })
|
||||
return { ...created, startupTerminalHandle: undefined }
|
||||
const createWorkspace =
|
||||
intent.target.kind === 'create-folder-workspace'
|
||||
? workspaces.createFolderWorkspace?.bind(workspaces)
|
||||
: workspaces.createWorktree.bind(workspaces)
|
||||
if (!createWorkspace) {
|
||||
throw new Error('agent_launch_workspace_factory_required')
|
||||
}
|
||||
const created = await workspaces.createWorktree({
|
||||
const created = await createWorkspace({
|
||||
// Reserved fields must come from the launch intent, not a stale create payload.
|
||||
create: withoutReservedAgentCreateFields(intent.target.create),
|
||||
...(onWorkspaceCreated ? { onWorkspaceCreated } : {}),
|
||||
|
||||
@@ -123,10 +123,7 @@ export type AgentLaunchWorkspaceFactory = {
|
||||
}>
|
||||
/** Leave a usable workspace when chat creation has an indeterminate outcome. */
|
||||
createRecoveryShell?: (worktreeId: string) => Promise<void>
|
||||
/** Folders have no startup terminal; the executor launches their agent after creation. */
|
||||
createFolderWorkspace?(args: {
|
||||
create: Readonly<Record<string, unknown>>
|
||||
}): Promise<{ worktreeId: string; workspacePath?: string; connectionId: string | null }>
|
||||
createFolderWorkspace?: AgentLaunchWorkspaceFactory['createWorktree']
|
||||
}
|
||||
|
||||
/** The options a terminal leg starts with: a caller's saved chat choice applies to it too. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { sessionCandidatesFromDiscoveries } from '../../ai-vault/discovery/sessi
|
||||
import { discoverAiVaultSessionSources } from '../../ai-vault/discovery/session-scanner-source-discovery'
|
||||
import type {
|
||||
AiVaultScanOptions,
|
||||
SessionFileCandidate,
|
||||
SessionFileChoice,
|
||||
SessionFileDiscovery
|
||||
} from '../../ai-vault/session-scanner-types'
|
||||
|
||||
@@ -19,7 +19,7 @@ export type SessionSearchScanRoots = Omit<
|
||||
|
||||
export type SessionSearchDiscovery = {
|
||||
/** Newest first with Codex hardlink aliases collapsed, matching session-list discovery. */
|
||||
candidates: SessionFileCandidate[]
|
||||
candidates: SessionFileChoice[]
|
||||
discoveries: SessionFileDiscovery[]
|
||||
issues: AiVaultScanIssue[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type * as TranscriptFs from '../../native-chat/transcript-filesystem/wsl-transcript-fs-access'
|
||||
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { resetSessionParseCacheForTests } from '../../ai-vault/parse-cache/session-parse-cache-store'
|
||||
import {
|
||||
resetTranscriptConsumersForTests,
|
||||
registerTranscriptConsumer
|
||||
} from '../../ai-vault/transcripts/session-transcript-consumers'
|
||||
import { physicalSessionCandidates } from '../../ai-vault/session-scanner-types'
|
||||
import { isolatedScanRoots } from '../../ai-vault/scanner-fixtures/session-scanner-test-fixtures'
|
||||
import {
|
||||
discoverSessionSearchCandidates,
|
||||
type SessionSearchScanRoots
|
||||
} from '../discovery/session-search-scan-roots'
|
||||
import { SessionSearchDirectoryListings } from '../discovery/session-search-directory-listings'
|
||||
import { SessionSearchStore } from '../storage/session-search-store'
|
||||
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
|
||||
import { runSessionSearchPass } from './session-search-pass'
|
||||
import {
|
||||
openSessionSearchIndexFile,
|
||||
type SessionSearchIndexFile
|
||||
} from '../storage/session-search-index-test-fixture'
|
||||
|
||||
const io = vi.hoisted((): { failed: string; paths: string[] } => ({ failed: '', paths: [] }))
|
||||
vi.mock(
|
||||
'../../native-chat/transcript-filesystem/wsl-transcript-fs-access',
|
||||
async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TranscriptFs>()
|
||||
return {
|
||||
...actual,
|
||||
openTranscriptReadStream: (...args: Parameters<typeof actual.openTranscriptReadStream>) => {
|
||||
io.paths.push(args[0])
|
||||
if (args[0] === io.failed) {
|
||||
return new Readable({
|
||||
read() {
|
||||
this.destroy(Object.assign(new Error('unreadable'), { code: 'EACCES' }))
|
||||
}
|
||||
})
|
||||
}
|
||||
return actual.openTranscriptReadStream(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let index: SessionSearchIndexFile
|
||||
let roots: SessionSearchScanRoots
|
||||
let store: SessionSearchStore
|
||||
let unregister: () => void
|
||||
let real: string
|
||||
let managed: string
|
||||
beforeEach(async () => {
|
||||
index = await openSessionSearchIndexFile('ss-codex-aliases')
|
||||
const root = dirname(index.path)
|
||||
roots = isolatedScanRoots(root)
|
||||
const home = join(root, 'real')
|
||||
const managedHome = join(root, 'codex-accounts', 'account', 'home')
|
||||
const name = 'rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
|
||||
real = join(home, 'sessions', name)
|
||||
managed = join(managedHome, 'sessions', name)
|
||||
roots = {
|
||||
...roots,
|
||||
codexSessionsDir: dirname(real),
|
||||
defaultCodexHomeDir: home,
|
||||
additionalCodexSessionsDirs: [dirname(managed)]
|
||||
}
|
||||
await mkdir(dirname(real), { recursive: true })
|
||||
await mkdir(dirname(managed), { recursive: true })
|
||||
await writeFile(
|
||||
real,
|
||||
`${[
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: { id: '019f0000-1111-7222-8333-444444444444', cwd: '/repo/app' }
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'readablealiasneedle' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join('\n')}\n`
|
||||
)
|
||||
await copyFile(real, managed)
|
||||
io.failed = real
|
||||
io.paths = []
|
||||
store = new SessionSearchStore(index.path, (error) => {
|
||||
throw error
|
||||
})
|
||||
unregister = registerSessionSearchIndexConsumer(store)
|
||||
})
|
||||
afterEach(async () => {
|
||||
unregister()
|
||||
resetTranscriptConsumersForTests()
|
||||
resetSessionParseCacheForTests()
|
||||
store.close()
|
||||
await index.close()
|
||||
io.failed = ''
|
||||
})
|
||||
const sweep = () =>
|
||||
runSessionSearchPass({
|
||||
store,
|
||||
roots,
|
||||
full: true,
|
||||
recentPerAgent: 100,
|
||||
listings: new SessionSearchDirectoryListings(),
|
||||
overdue: () => true
|
||||
})
|
||||
function matches() {
|
||||
return index.db
|
||||
.prepare("SELECT user_text FROM messages_fts WHERE messages_fts MATCH 'readablealiasneedle'")
|
||||
.all()
|
||||
}
|
||||
|
||||
it('indexes a readable alias despite a failed preferred open and an overdue pass, then reuses it after restart', async () => {
|
||||
await sweep()
|
||||
expect(matches()).toEqual([{ user_text: 'readablealiasneedle' }])
|
||||
const discovered = await discoverSessionSearchCandidates(roots, {
|
||||
limitPerAgent: Infinity
|
||||
})
|
||||
const candidate = discovered.candidates
|
||||
.flatMap(physicalSessionCandidates)
|
||||
.find((item) => item.file.path === managed)!
|
||||
expect(store.coversTranscript(candidate)).toBe(true)
|
||||
// Hold out the failed real path without suppressing the committed managed copy.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
store.setFileState(real, 'failed', discovered.candidates[0].file.mtimeMs)
|
||||
}
|
||||
io.paths = []
|
||||
await sweep()
|
||||
expect(io.paths).toEqual([])
|
||||
expect(matches()).toEqual([{ user_text: 'readablealiasneedle' }])
|
||||
unregister()
|
||||
store.close()
|
||||
resetSessionParseCacheForTests()
|
||||
store = new SessionSearchStore(index.path, (error) => {
|
||||
throw error
|
||||
})
|
||||
unregister = registerSessionSearchIndexConsumer(store)
|
||||
await sweep()
|
||||
expect(io.paths).toEqual([])
|
||||
expect(matches()).toEqual([{ user_text: 'readablealiasneedle' }])
|
||||
})
|
||||
|
||||
it('does not try another source when a consumer drain fails', async () => {
|
||||
io.failed = ''
|
||||
const stop = registerTranscriptConsumer({
|
||||
beginRead: () => ({
|
||||
message() {},
|
||||
finish() {},
|
||||
drain: async () => {
|
||||
throw new Error('store declined')
|
||||
}
|
||||
})
|
||||
})
|
||||
try {
|
||||
await sweep()
|
||||
expect(io.paths).toEqual([real])
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not try another source when flushing the writer fails', async () => {
|
||||
io.failed = ''
|
||||
const flush = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error('flush failed'))
|
||||
.mockResolvedValue(undefined)
|
||||
Object.assign(store, { flush })
|
||||
await sweep()
|
||||
expect(io.paths).toEqual([real])
|
||||
})
|
||||
|
||||
it('keeps a successfully excluded canonical rollout excluded on unchanged sweeps', async () => {
|
||||
const body = [
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: '019f0000-1111-7222-8333-444444444444',
|
||||
source: { internal: 'guardian_review' },
|
||||
cwd: '/repo/app'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'readablealiasneedle' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join('\n')
|
||||
await writeFile(real, `${body}\n`)
|
||||
await copyFile(real, managed)
|
||||
io.failed = ''
|
||||
await sweep()
|
||||
expect(matches()).toEqual([])
|
||||
io.paths = []
|
||||
await sweep()
|
||||
expect(io.paths).toEqual([])
|
||||
expect(matches()).toEqual([])
|
||||
})
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
parseAgentSessionFileCached,
|
||||
type SessionParseStats
|
||||
} from '../../ai-vault/parse-cache/session-scanner-parse-cache'
|
||||
import type { SessionFileCandidate } from '../../ai-vault/session-scanner-types'
|
||||
import { isTranscriptSourceFailure } from '../../ai-vault/parsing/session-scanner-jsonl-reader'
|
||||
import {
|
||||
physicalSessionCandidates,
|
||||
type SessionFileChoice
|
||||
} from '../../ai-vault/session-scanner-types'
|
||||
import { fileIdentity } from '../storage/session-search-file-cursor'
|
||||
import { sessionSearchReadDecision } from './session-search-read-decision'
|
||||
import type {
|
||||
@@ -23,57 +27,93 @@ export type SessionSearchIndexPassOptions = {
|
||||
/** Deferred candidates stay owed by their stored state; left counts backlog outside the due-row count. */
|
||||
export async function runSessionSearchIndexPass(
|
||||
store: SessionSearchIndexStore,
|
||||
candidates: readonly SessionFileCandidate[],
|
||||
candidates: readonly SessionFileChoice[],
|
||||
options: SessionSearchIndexPassOptions
|
||||
): Promise<{ stats: SessionParseStats; outOfTime: boolean; left: number }> {
|
||||
const stats = createSessionParseStats()
|
||||
const cutoffMs = store.retentionCutoff
|
||||
const acceptsReads = () => store.acceptingReads !== false
|
||||
let read = 0
|
||||
let outOfTime = false
|
||||
let left = 0
|
||||
for (const candidate of candidates) {
|
||||
for (const choice of candidates) {
|
||||
throwIfAiVaultScanCancelled(options.signal)
|
||||
const path = candidate.file.path
|
||||
const row = options.rows.get(path)
|
||||
const decision = sessionSearchReadDecision({
|
||||
candidate,
|
||||
row,
|
||||
// New paths already require reading; avoid a redundant cursor query per file.
|
||||
cursor: row ? store.indexedFile(path, fileIdentity(candidate.file)) : null,
|
||||
cutoffMs
|
||||
})
|
||||
if (decision === 'skip') {
|
||||
continue
|
||||
if (!acceptsReads()) {
|
||||
break
|
||||
}
|
||||
// Continue decisions after the read deadline to count remaining backlog.
|
||||
outOfTime ||= read > 0 && options.overdue?.() === true
|
||||
if (outOfTime) {
|
||||
// Status adds left to due-row counts; exclude due rows here to avoid double counting.
|
||||
if (row?.state !== 'due') {
|
||||
left += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Deadline callbacks may close the owner; recheck cancellation before touching the store.
|
||||
throwIfAiVaultScanCancelled(options.signal)
|
||||
read += 1
|
||||
try {
|
||||
await parseAgentSessionFileCached(
|
||||
candidate,
|
||||
process.platform,
|
||||
stats,
|
||||
decision,
|
||||
options.signal,
|
||||
true
|
||||
)
|
||||
await store.flush?.()
|
||||
} catch (error) {
|
||||
let deferred = false
|
||||
let due = false
|
||||
let admitted = false
|
||||
for (const candidate of physicalSessionCandidates(choice)) {
|
||||
throwIfAiVaultScanCancelled(options.signal)
|
||||
console.warn(
|
||||
'[ai-vault-search] indexing skipped',
|
||||
candidate.agent,
|
||||
error instanceof Error ? error.name : 'ParseError'
|
||||
)
|
||||
if (!acceptsReads()) {
|
||||
break
|
||||
}
|
||||
const path = candidate.file.path
|
||||
const row = options.rows.get(path)
|
||||
const cursor = row ? store.indexedFile(path, fileIdentity(candidate.file)) : null
|
||||
const decision = sessionSearchReadDecision({ candidate, row, cursor, cutoffMs })
|
||||
if (decision === 'skip') {
|
||||
if (
|
||||
(cutoffMs === null || candidate.file.mtimeMs >= cutoffMs) &&
|
||||
row?.state === 'current' &&
|
||||
cursor !== null
|
||||
) {
|
||||
deferred = false
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!admitted) {
|
||||
outOfTime ||= read > 0 && options.overdue?.() === true
|
||||
}
|
||||
if (outOfTime && !admitted) {
|
||||
deferred = true
|
||||
due ||= row?.state === 'due'
|
||||
continue
|
||||
}
|
||||
throwIfAiVaultScanCancelled(options.signal)
|
||||
if (!acceptsReads()) {
|
||||
break
|
||||
}
|
||||
admitted = true
|
||||
read += 1
|
||||
try {
|
||||
await parseAgentSessionFileCached(
|
||||
candidate,
|
||||
process.platform,
|
||||
stats,
|
||||
decision,
|
||||
options.signal,
|
||||
true
|
||||
)
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(options.signal)
|
||||
console.warn(
|
||||
'[ai-vault-search] indexing skipped',
|
||||
candidate.agent,
|
||||
error instanceof Error ? error.name : 'ParseError'
|
||||
)
|
||||
if (isTranscriptSourceFailure(error)) {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
// Consumer/store failures cannot authorize reading another physical alias.
|
||||
try {
|
||||
await store.flush?.()
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(options.signal)
|
||||
console.warn(
|
||||
'[ai-vault-search] indexing skipped',
|
||||
candidate.agent,
|
||||
error instanceof Error ? error.name : 'FlushError'
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
if (deferred && !due) {
|
||||
left += 1
|
||||
}
|
||||
}
|
||||
return { stats, outOfTime, left }
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
physicalSessionCandidates,
|
||||
type SessionFileChoice
|
||||
} from '../../ai-vault/session-scanner-types'
|
||||
import type { AiVaultScanIssue } from '../../../shared/ai-vault/contracts/ai-vault-types'
|
||||
import {
|
||||
cursorChatMetaRefusals,
|
||||
@@ -108,7 +112,10 @@ export async function runSessionSearchPass(
|
||||
roots,
|
||||
// Only unlimited discovery can prove a synthetic session is absent from its container.
|
||||
enumeratedContainers: args.full
|
||||
? sessionSearchEnumeratedContainers(swept.candidates, issues)
|
||||
? sessionSearchEnumeratedContainers(
|
||||
swept.candidates.flatMap(physicalSessionCandidates),
|
||||
issues
|
||||
)
|
||||
: undefined,
|
||||
emptiedRoots: previousRootsWithFiles
|
||||
? sessionSearchEmptiedRoots(previousRootsWithFiles, rootsWithFiles)
|
||||
@@ -153,11 +160,13 @@ export async function runSessionSearchPass(
|
||||
/** Retire undiscovered rows; recent cycles check only the newest rows under discovered roots. */
|
||||
function retirementCandidates(
|
||||
rows: ReadonlyMap<string, SessionSearchFileRow>,
|
||||
swept: { candidates: readonly { file: { path: string } }[] },
|
||||
swept: { candidates: readonly SessionFileChoice[] },
|
||||
roots: readonly string[],
|
||||
full: boolean
|
||||
): string[] {
|
||||
const discovered = new Set(swept.candidates.map((candidate) => candidate.file.path))
|
||||
const discovered = new Set(
|
||||
swept.candidates.flatMap(physicalSessionCandidates).map((candidate) => candidate.file.path)
|
||||
)
|
||||
const undiscovered = [...rows.values()].filter((row) => !discovered.has(row.path))
|
||||
if (full) {
|
||||
return undiscovered.map((row) => row.path)
|
||||
|
||||
@@ -2,7 +2,9 @@ import { prioritizeAntigravityTranscriptCandidates } from '../scanners/antigravi
|
||||
import { readCodexRolloutSessionMetaId } from '../../codex/history/rollouts/codex-rollout-session-meta'
|
||||
import {
|
||||
codexRolloutHardlinkIdentity,
|
||||
dedupeCodexRolloutAliases,
|
||||
canonicalCodexRolloutKey,
|
||||
codexSessionRootRank,
|
||||
dedupeCodexRolloutCopyAliases,
|
||||
dedupeCodexRolloutFileAliases
|
||||
} from '../scanners/codex/codex-session-root-dedup'
|
||||
import { antigravityHistoryPathForBrainDir } from '../scanners/antigravity/session-scanner-antigravity-paths'
|
||||
@@ -12,6 +14,7 @@ import type {
|
||||
AiVaultScanOptions,
|
||||
FileWithMtime,
|
||||
SessionFileCandidate,
|
||||
SessionFileChoice,
|
||||
SessionFileDiscovery
|
||||
} from '../session-scanner-types'
|
||||
|
||||
@@ -69,17 +72,46 @@ function dropOmpCandidatesListedByPi(
|
||||
export async function sessionCandidatesFromDiscoveries(
|
||||
discoveries: SessionFileDiscovery[],
|
||||
options: AiVaultScanOptions
|
||||
): Promise<SessionFileCandidate[]> {
|
||||
const candidates = await dedupeCodexRolloutAliases(
|
||||
): Promise<SessionFileChoice[]> {
|
||||
const physical = dropCodexHardlinkAliases(
|
||||
dropOmpCandidatesListedByPi(
|
||||
discoveries.flatMap((discovery) =>
|
||||
discoveryFileCandidates(discovery, discovery.files, options)
|
||||
)
|
||||
).sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
|
||||
).sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
|
||||
)
|
||||
const canonical = new Map<string, SessionFileCandidate[]>()
|
||||
const legacy: SessionFileCandidate[] = []
|
||||
for (const candidate of physical) {
|
||||
const key = candidate.agent === 'codex' ? canonicalCodexRolloutKey(candidate.file.path) : null
|
||||
if (key === null) {
|
||||
legacy.push(candidate)
|
||||
} else {
|
||||
const group = canonical.get(key)
|
||||
if (group) {
|
||||
group.push(candidate)
|
||||
} else {
|
||||
canonical.set(key, [candidate])
|
||||
}
|
||||
}
|
||||
}
|
||||
const candidates: SessionFileChoice[] = await dedupeCodexRolloutCopyAliases(
|
||||
legacy,
|
||||
CODEX_ALIAS_ACCESSORS,
|
||||
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
|
||||
options.signal
|
||||
)
|
||||
for (const group of canonical.values()) {
|
||||
group.sort(
|
||||
(a, b) =>
|
||||
Number(a.file.sizeBytes === 0) - Number(b.file.sizeBytes === 0) ||
|
||||
codexSessionRootRank(a.codexHome) - codexSessionRootRank(b.codexHome) ||
|
||||
Number(a.file.path.endsWith('.zst')) - Number(b.file.path.endsWith('.zst')) ||
|
||||
(a.file.path < b.file.path ? -1 : a.file.path > b.file.path ? 1 : 0)
|
||||
)
|
||||
candidates.push(group.length === 1 ? group[0] : { ...group[0], alternatives: group })
|
||||
}
|
||||
candidates.sort((a, b) => b.file.mtimeMs - a.file.mtimeMs)
|
||||
return prioritizeAntigravityTranscriptCandidates(
|
||||
candidates,
|
||||
(candidate) => candidate.agent === 'antigravity'
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { isTranscriptSourceFailure } from './session-scanner-jsonl-reader'
|
||||
import {
|
||||
physicalSessionCandidates,
|
||||
type SessionFileCandidate,
|
||||
type SessionFileChoice,
|
||||
type SessionParseResult
|
||||
} from '../session-scanner-types'
|
||||
import { yieldToEventLoop } from '../../../shared/event-loop-yield'
|
||||
import { isTextTaskRequestSession } from './text-task-request-sessions'
|
||||
import { candidateFileTime } from '../scanners/antigravity/antigravity-transcript-candidates'
|
||||
@@ -24,14 +31,13 @@ import {
|
||||
sessionParseCacheKey
|
||||
} from '../parse-cache/session-parse-cache-store'
|
||||
import { canStopParsingSessions } from './session-scan-cutoff'
|
||||
import type { SessionFileCandidate, SessionParseResult } from '../session-scanner-types'
|
||||
import { throwIfAiVaultScanCancelled } from '../scan-lifecycle/ai-vault-scan-cancellation'
|
||||
import { noteAiVaultScanWorkChanged } from '../scan-lifecycle/ai-vault-scan-progress'
|
||||
|
||||
const SESSION_PARSE_CONCURRENCY = 8
|
||||
|
||||
export async function parseSessionCandidates(args: {
|
||||
candidates: SessionFileCandidate[]
|
||||
candidates: SessionFileChoice[]
|
||||
limit: number
|
||||
platform: NodeJS.Platform
|
||||
executionHostId: ExecutionHostId
|
||||
@@ -79,44 +85,72 @@ export async function parseSessionCandidates(args: {
|
||||
)
|
||||
const batch = args.candidates.slice(index, index + batchSize)
|
||||
index += batchSize
|
||||
const verdicts = args.selectCandidate
|
||||
? await Promise.all(batch.map(args.selectCandidate))
|
||||
: batch.map(() => true)
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
const selected = batch.filter((_, batchIndex) => verdicts[batchIndex])
|
||||
const results = await Promise.all(
|
||||
selected.map((candidate) =>
|
||||
parseSessionCandidate(candidate, {
|
||||
platform: args.platform,
|
||||
executionHostId: args.executionHostId,
|
||||
parseStats: args.parseStats,
|
||||
antigravityWorkspaceResolver: args.antigravityWorkspaceResolver,
|
||||
listed: args.listed ?? true,
|
||||
signal: args.signal
|
||||
})
|
||||
)
|
||||
batch.map(async (choice) => {
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
let selected = false
|
||||
let provisional: { candidate: SessionFileCandidate; session: AiVaultSession } | undefined
|
||||
let accepted: typeof provisional
|
||||
for (const candidate of physicalSessionCandidates(choice)) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (args.selectCandidate && !(await args.selectCandidate(candidate))) {
|
||||
continue
|
||||
}
|
||||
selected = true
|
||||
const result = await parseSessionCandidate(candidate, {
|
||||
platform: args.platform,
|
||||
executionHostId: args.executionHostId,
|
||||
parseStats: args.parseStats,
|
||||
antigravityWorkspaceResolver: args.antigravityWorkspaceResolver,
|
||||
listed: args.listed ?? true,
|
||||
signal: args.signal
|
||||
})
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (result.issue) {
|
||||
issues.push(result.issue)
|
||||
}
|
||||
if (result.sourceFailed) {
|
||||
continue
|
||||
}
|
||||
if (!result.session && result.issue) {
|
||||
break
|
||||
}
|
||||
if (!result.session || isTextTaskRequestSession(result.session)) {
|
||||
provisional = undefined
|
||||
break
|
||||
}
|
||||
if (args.admitSession && !args.admitSession(result.session)) {
|
||||
continue
|
||||
}
|
||||
const parsed = { candidate, session: result.session }
|
||||
if (
|
||||
isAiVaultSessionResumableContent(result.session) ||
|
||||
isAiVaultSessionRecoverableEmpty(result.session)
|
||||
) {
|
||||
accepted = parsed
|
||||
break
|
||||
}
|
||||
provisional ??= parsed
|
||||
}
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
return { winner: accepted ?? provisional, selected, issues }
|
||||
})
|
||||
)
|
||||
// Cancellation during a batch must propagate before partial results become issues or listed sessions.
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
batch.forEach((candidate, index) => args.settled?.(candidate, verdicts[index]))
|
||||
noteAiVaultScanWorkChanged(args.signal)
|
||||
|
||||
results.forEach((result, resultIndex) => {
|
||||
if (result.issue) {
|
||||
recordSessionScanIssue(args.issues, result.issue)
|
||||
results.forEach(({ winner, selected, issues }, index) => {
|
||||
for (const issue of issues) {
|
||||
recordSessionScanIssue(args.issues, issue)
|
||||
}
|
||||
if (
|
||||
result.session &&
|
||||
!isTextTaskRequestSession(result.session) &&
|
||||
(!args.admitSession || args.admitSession(result.session))
|
||||
) {
|
||||
if (winner) {
|
||||
if (args.listed === false) {
|
||||
const { agent, file } = selected[resultIndex]
|
||||
const { agent, file } = winner.candidate
|
||||
noteSessionParseCacheListed(sessionParseCacheKey(agent, file.path))
|
||||
}
|
||||
sessions.add(result.session)
|
||||
sessions.add(winner.session)
|
||||
}
|
||||
args.settled?.(batch[index], selected)
|
||||
})
|
||||
noteAiVaultScanWorkChanged(args.signal)
|
||||
}
|
||||
|
||||
for (; index < args.candidates.length; index++) {
|
||||
@@ -157,6 +191,7 @@ export async function parseSessionCandidate(
|
||||
} catch (err) {
|
||||
return {
|
||||
session: null,
|
||||
sourceFailed: isTranscriptSourceFailure(err),
|
||||
issue: {
|
||||
executionHostId,
|
||||
agent: candidate.agent,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { Readable } from 'node:stream'
|
||||
import { isAiVaultScanCancelledError } from '../../../shared/ai-vault/contracts/ai-vault-types'
|
||||
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
|
||||
import { MAX_SESSION_TRANSCRIPT_RECORD_BYTES } from '../transcripts/session-transcript-record-budget'
|
||||
@@ -11,16 +12,18 @@ type ChunkSource = {
|
||||
|
||||
const source = vi.hoisted((): ChunkSource => ({ chunks: [], closed: false, opens: 0 }))
|
||||
vi.mock('../../native-chat/transcript-filesystem/wsl-transcript-fs-access', () => ({
|
||||
openTranscriptReadStream: async function* () {
|
||||
openTranscriptReadStream: () => {
|
||||
source.opens += 1
|
||||
source.closed = false
|
||||
try {
|
||||
for (const chunk of source.chunks) {
|
||||
yield chunk
|
||||
}
|
||||
} finally {
|
||||
source.closed = true
|
||||
}
|
||||
return Readable.from(
|
||||
(function* () {
|
||||
try {
|
||||
yield* source.chunks
|
||||
} finally {
|
||||
source.closed = true
|
||||
}
|
||||
})()
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ import {
|
||||
type SkippedTranscriptRecord
|
||||
} from '../transcripts/session-transcript-record-budget'
|
||||
|
||||
const sourceFailures = new WeakSet<object>()
|
||||
|
||||
export function noteTranscriptSourceFailure(error: unknown): void {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
sourceFailures.add(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function isTranscriptSourceFailure(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && sourceFailures.has(error)
|
||||
}
|
||||
|
||||
const NEWLINE_BYTE = 0x0a
|
||||
const CARRIAGE_RETURN_BYTE = 0x0d
|
||||
|
||||
@@ -72,13 +84,24 @@ export async function consumeCompleteJsonlLines(args: {
|
||||
if (compressed && args.start !== 0) {
|
||||
throw new Error(`compressed transcript cannot resume at byte ${args.start}: ${args.path}`)
|
||||
}
|
||||
const rawStream = openTranscriptReadStream(
|
||||
args.path,
|
||||
{ start: compressed ? 0 : args.start },
|
||||
'scan',
|
||||
args.signal
|
||||
)
|
||||
const stream = compressed ? zstdTranscriptStream(rawStream) : rawStream
|
||||
let rawStream: ReturnType<typeof openTranscriptReadStream>
|
||||
let stream: ReturnType<typeof zstdTranscriptStream>
|
||||
try {
|
||||
rawStream = openTranscriptReadStream(
|
||||
args.path,
|
||||
{ start: compressed ? 0 : args.start },
|
||||
'scan',
|
||||
args.signal
|
||||
)
|
||||
rawStream.on('error', noteTranscriptSourceFailure)
|
||||
stream = compressed ? zstdTranscriptStream(rawStream) : rawStream
|
||||
if (stream !== rawStream) {
|
||||
stream.on('error', noteTranscriptSourceFailure)
|
||||
}
|
||||
} catch (error) {
|
||||
noteTranscriptSourceFailure(error)
|
||||
throw error
|
||||
}
|
||||
let chunkStart = args.start
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
@@ -155,6 +178,11 @@ export async function consumeCompleteJsonlLines(args: {
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
throw error
|
||||
} finally {
|
||||
rawStream.off('error', noteTranscriptSourceFailure)
|
||||
if (stream !== rawStream) {
|
||||
stream.off('error', noteTranscriptSourceFailure)
|
||||
}
|
||||
}
|
||||
|
||||
if (discarding) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { sessionSortTime } from '../../parsing/session-scanner-accumulator'
|
||||
|
||||
// Bridging and backfill expose one rollout through multiple roots; list only its canonical alias (#7521).
|
||||
|
||||
// Rollout names narrow candidates; inode or parsed session ID must still prove identity.
|
||||
// Legacy names require content proof; canonical names encode immutable rollout identity.
|
||||
const CODEX_ROLLOUT_FILE_NAME_PATTERN = /^rollout-.+\.jsonl(?:\.zst)?$/
|
||||
|
||||
// Hosts can scan foreign-platform paths, so handle both separators independently of node:path.
|
||||
@@ -26,6 +26,25 @@ function codexLiveAliasBeats(filePath: string, bestPath: string): boolean | null
|
||||
return archived === bestPath.endsWith('.zst') ? null : !archived
|
||||
}
|
||||
|
||||
const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
|
||||
const CANONICAL_ROLLOUT = new RegExp(
|
||||
`^rollout-(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})-${UUID}(?:_${UUID})?\\.jsonl$`
|
||||
)
|
||||
|
||||
export function canonicalCodexRolloutKey(filePath: string): string | null {
|
||||
const name = codexRolloutAliasName(lastPathSegment(filePath))
|
||||
const match = CANONICAL_ROLLOUT.exec(name)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const iso = `${match[1]}T${match[2]}:${match[3]}:${match[4]}.000Z`
|
||||
const time = Date.parse(iso)
|
||||
if (!Number.isFinite(time) || new Date(time).toISOString() !== iso) {
|
||||
return null
|
||||
}
|
||||
return `${sessionFileExecutionNamespace(filePath)}\0${name}`
|
||||
}
|
||||
|
||||
export function codexRolloutHardlinkIdentity(file: {
|
||||
dev?: number
|
||||
ino?: number
|
||||
@@ -48,7 +67,7 @@ export function codexRolloutHardlinkIdentity(file: {
|
||||
}
|
||||
|
||||
/** Prefer the real host home, then managed homes, so resume uses the canonical authentication lane. */
|
||||
function codexSessionRootRank(codexHome: string | null): number {
|
||||
export function codexSessionRootRank(codexHome: string | null): number {
|
||||
if (codexHome === null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
import { copyFile, link, mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import type * as TranscriptFs from '../../../native-chat/transcript-filesystem/wsl-transcript-fs-access'
|
||||
import { appendFile, copyFile, link, mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Readable } from 'node:stream'
|
||||
import { resetSessionParseCacheForTests } from '../../parse-cache/session-parse-cache-store'
|
||||
import { sessionCandidatesFromDiscoveries } from '../../discovery/session-scanner-candidates'
|
||||
import { scanAiVaultSessions } from '../../session-scanner'
|
||||
import { isolatedScanRoots, jsonLines } from '../../scanner-fixtures/session-scanner-test-fixtures'
|
||||
|
||||
const reads = vi.hoisted(
|
||||
(): {
|
||||
failed: string
|
||||
failedSlice: string
|
||||
cancel: AbortController | null
|
||||
paths: string[]
|
||||
} => ({
|
||||
failed: '',
|
||||
failedSlice: '',
|
||||
cancel: null,
|
||||
paths: []
|
||||
})
|
||||
)
|
||||
vi.mock(
|
||||
'../../../native-chat/transcript-filesystem/wsl-transcript-fs-access',
|
||||
async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TranscriptFs>()
|
||||
return {
|
||||
...actual,
|
||||
readTranscriptSlice: async (...args: Parameters<typeof actual.readTranscriptSlice>) => {
|
||||
if (args[0] === reads.failedSlice) {
|
||||
throw Object.assign(new Error('unreadable'), { code: 'EACCES' })
|
||||
}
|
||||
return actual.readTranscriptSlice(...args)
|
||||
},
|
||||
openTranscriptReadStream: (...args: Parameters<typeof actual.openTranscriptReadStream>) => {
|
||||
reads.paths.push(args[0])
|
||||
if (args[0] === reads.failed) {
|
||||
reads.cancel?.abort()
|
||||
return new Readable({
|
||||
read() {
|
||||
this.destroy(Object.assign(new Error('unreadable'), { code: 'EACCES' }))
|
||||
}
|
||||
})
|
||||
}
|
||||
return actual.openTranscriptReadStream(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
reads.failed = ''
|
||||
reads.failedSlice = ''
|
||||
reads.cancel = null
|
||||
reads.paths = []
|
||||
resetSessionParseCacheForTests()
|
||||
})
|
||||
|
||||
describe('scanAiVaultSessions codex dual-root dedup', () => {
|
||||
@@ -255,3 +305,150 @@ describe('scanAiVaultSessions codex dual-root dedup', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function canonicalCopies(nonUser = false) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-canonical-choice-'))
|
||||
tempRoots.push(root)
|
||||
const realHome = join(root, 'real-home')
|
||||
const managedHome = join(root, 'codex-accounts', 'account', 'home')
|
||||
const name = 'rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
|
||||
const real = join(realHome, 'sessions', name)
|
||||
const managed = join(managedHome, 'sessions', name)
|
||||
await mkdir(join(realHome, 'sessions'), { recursive: true })
|
||||
await mkdir(join(managedHome, 'sessions'), { recursive: true })
|
||||
await writeFile(
|
||||
real,
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: '019f0000-1111-7222-8333-444444444444',
|
||||
cwd: '/repo/app',
|
||||
...(nonUser ? { source: { internal: 'guardian_review' } } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'readablealiasneedle' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
await appendFile(real, '\n')
|
||||
await copyFile(real, managed)
|
||||
return {
|
||||
real,
|
||||
managed,
|
||||
managedHome,
|
||||
options: {
|
||||
...isolatedScanRoots(root),
|
||||
codexSessionsDir: join(realHome, 'sessions'),
|
||||
defaultCodexHomeDir: realHome,
|
||||
additionalCodexSessionsDirs: [join(managedHome, 'sessions')],
|
||||
platform: 'darwin' as const,
|
||||
limit: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('falls back to a readable canonical copy with its own resume ownership, then recovers the real home', async () => {
|
||||
const fixture = await canonicalCopies()
|
||||
reads.failed = fixture.real
|
||||
const first = await scanAiVaultSessions(fixture.options)
|
||||
expect(first.sessions).toHaveLength(1)
|
||||
expect(first.sessions[0]).toMatchObject({
|
||||
filePath: fixture.managed,
|
||||
codexHome: fixture.managedHome,
|
||||
title: 'readablealiasneedle'
|
||||
})
|
||||
expect(first.sessions[0].resumeCommand).toContain(`CODEX_HOME='${fixture.managedHome}'`)
|
||||
reads.failed = ''
|
||||
const recovered = await scanAiVaultSessions(fixture.options)
|
||||
expect(recovered.sessions).toHaveLength(1)
|
||||
expect(recovered.sessions[0]).toMatchObject({ filePath: fixture.real, codexHome: null })
|
||||
})
|
||||
|
||||
it('does not open an alternate after cancellation', async () => {
|
||||
const fixture = await canonicalCopies()
|
||||
reads.failed = fixture.real
|
||||
reads.cancel = new AbortController()
|
||||
await expect(
|
||||
scanAiVaultSessions({ ...fixture.options, signal: reads.cancel.signal })
|
||||
).rejects.toThrow()
|
||||
expect(reads.paths).not.toContain(fixture.managed)
|
||||
})
|
||||
|
||||
it('excludes a canonical non-user rollout after one physical attempt', async () => {
|
||||
const fixture = await canonicalCopies(true)
|
||||
expect((await scanAiVaultSessions(fixture.options)).sessions).toEqual([])
|
||||
expect(reads.paths).toEqual([fixture.real])
|
||||
})
|
||||
|
||||
it('tries readable content after a provisional filename-only row', async () => {
|
||||
const fixture = await canonicalCopies()
|
||||
await writeFile(fixture.real, '{}\n')
|
||||
const result = await scanAiVaultSessions(fixture.options)
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
filePath: fixture.managed,
|
||||
title: 'readablealiasneedle'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps distinct canonical rollouts and execution namespaces, preferring nonempty copies', async () => {
|
||||
const name = 'rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444'
|
||||
const paths = [
|
||||
`/real/${name}.jsonl`,
|
||||
`/managed/${name}.jsonl`,
|
||||
`/real/${name}_019f0000-2222-7222-8333-444444444444.jsonl`,
|
||||
`\\\\wsl.localhost\\Ubuntu\\home\\neil\\${name}.jsonl`
|
||||
]
|
||||
const candidates = await sessionCandidatesFromDiscoveries(
|
||||
[
|
||||
{
|
||||
agent: 'codex',
|
||||
rootDir: '/real',
|
||||
files: paths.map((path, i) => ({
|
||||
path,
|
||||
mtimeMs: 1,
|
||||
modifiedAt: '2026-07-01T10:00:00Z',
|
||||
sizeBytes: i === 0 ? 0 : 100
|
||||
}))
|
||||
}
|
||||
],
|
||||
{}
|
||||
)
|
||||
expect(candidates.map((choice) => choice.file.path).sort()).toEqual(paths.slice(1).sort())
|
||||
expect(
|
||||
candidates
|
||||
.find((choice) => choice.file.path === paths[1])
|
||||
?.alternatives?.map((candidate) => candidate.file.path)
|
||||
).toEqual([paths[1], paths[0]])
|
||||
})
|
||||
|
||||
it('falls back when a growing cached preferred file fails its resume boundary read', async () => {
|
||||
const fixture = await canonicalCopies()
|
||||
expect((await scanAiVaultSessions(fixture.options)).sessions[0].filePath).toBe(fixture.real)
|
||||
await appendFile(
|
||||
fixture.real,
|
||||
jsonLines([
|
||||
{
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'newaliasmessage' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
await appendFile(fixture.real, '\n')
|
||||
await copyFile(fixture.real, fixture.managed)
|
||||
reads.failedSlice = fixture.real
|
||||
const result = await scanAiVaultSessions(fixture.options)
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({ filePath: fixture.managed, messageCount: 2 })
|
||||
})
|
||||
|
||||
@@ -88,6 +88,17 @@ export type SessionFileCandidate = {
|
||||
antigravityHistoryPath?: string
|
||||
}
|
||||
|
||||
// Pass-local wrapper; only its original physical candidates may reach parsers or IPC.
|
||||
export type SessionFileChoice = SessionFileCandidate & {
|
||||
alternatives?: readonly SessionFileCandidate[]
|
||||
}
|
||||
|
||||
export function physicalSessionCandidates(
|
||||
choice: SessionFileChoice
|
||||
): readonly SessionFileCandidate[] {
|
||||
return choice.alternatives ?? [choice]
|
||||
}
|
||||
|
||||
export type SessionFileDiscovery = {
|
||||
agent: AiVaultAgent
|
||||
rootDir: string
|
||||
@@ -99,6 +110,7 @@ export type SessionFileDiscovery = {
|
||||
}
|
||||
|
||||
export type SessionParseResult = {
|
||||
sourceFailed?: boolean
|
||||
session: AiVaultSession | null
|
||||
issue: AiVaultScanIssue | null
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { createHash } from 'node:crypto'
|
||||
import { readTranscriptSlice } from '../../native-chat/transcript-filesystem/wsl-transcript-fs-access'
|
||||
import type { AiVaultSession } from '../../../shared/ai-vault/contracts/ai-vault-types'
|
||||
import { parseAgentSessionFile } from '../parsing/session-scanner-agent-parser'
|
||||
import { consumeCompleteJsonlLines } from '../parsing/session-scanner-jsonl-reader'
|
||||
import {
|
||||
consumeCompleteJsonlLines,
|
||||
noteTranscriptSourceFailure
|
||||
} from '../parsing/session-scanner-jsonl-reader'
|
||||
import { isZstdTranscriptPath } from '../../native-chat/transcript-streaming/transcript-zstd-decompression'
|
||||
import type { ResumableSessionParseState, SessionFileCandidate } from '../session-scanner-types'
|
||||
import {
|
||||
@@ -241,9 +244,14 @@ async function endsWithNewlineAt(
|
||||
offset: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const slice = await waitForAiVaultScan(
|
||||
readTranscriptSlice(path, offset - 1, 1, 'scan', signal),
|
||||
signal
|
||||
)
|
||||
return slice.length === 1 && slice[0] === NEWLINE_BYTE
|
||||
try {
|
||||
const slice = await waitForAiVaultScan(
|
||||
readTranscriptSlice(path, offset - 1, 1, 'scan', signal),
|
||||
signal
|
||||
)
|
||||
return slice.length === 1 && slice[0] === NEWLINE_BYTE
|
||||
} catch (error) {
|
||||
noteTranscriptSourceFailure(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@ vi.mock('fs', () => ({
|
||||
vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' }))
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false },
|
||||
webContents: { fromId: webContentsFromIdMock }
|
||||
webContents: { fromId: webContentsFromIdMock },
|
||||
BrowserWindow: { fromWebContents: () => null }
|
||||
}))
|
||||
const { CdpWsProxyMock } = vi.hoisted(() => {
|
||||
const instances: unknown[] = []
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import type { ResolvedBrowserCommandTarget } from '../session-routing/agent-browser-command-target'
|
||||
import { BrowserError } from '../../browser-error'
|
||||
import { normalizeCdpPointerButton } from '../../cdp/input/cdp-pointer-targets'
|
||||
@@ -91,17 +92,20 @@ export abstract class AgentBrowserBridgePointerCommands extends AgentBrowserBrid
|
||||
)
|
||||
}
|
||||
|
||||
// Guard embedded-guest presses to restore user focus; emulation prevents page blur.
|
||||
// Offscreen pages need native focus for caret and IME input.
|
||||
// Emulation keeps the page focused for the agent without taking the user's keyboard.
|
||||
protected async focusPageForPress(
|
||||
wc: Electron.WebContents,
|
||||
webContentsId: number
|
||||
): Promise<() => void> {
|
||||
await enablePageFocusEmulation((method, params) => wc.debugger.sendCommand(method, params))
|
||||
if (!isEmbeddedGuest(wc)) {
|
||||
wc.focus()
|
||||
// Native focus activates and raises a popup or serve window, so only use it inside a focused one.
|
||||
// fromWebContents also returns the BaseWindow that hosts a popup's view.
|
||||
if (BrowserWindow.fromWebContents(wc)?.isFocused()) {
|
||||
wc.focus()
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
await enablePageFocusEmulation((method, params) => wc.debugger.sendCommand(method, params))
|
||||
return await this.browserManager.guardUserFocusForAgentInput(webContentsId)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } =
|
||||
vi.hoisted(() => {
|
||||
const stdinWrites: string[] = []
|
||||
return {
|
||||
execFileMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(() => false),
|
||||
readFileSyncMock: vi.fn(() => Buffer.from('')),
|
||||
stdinWrites
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
accessSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
constants: { X_OK: 1 }
|
||||
}))
|
||||
vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' }))
|
||||
vi.mock('electron', () => {
|
||||
return {
|
||||
app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false },
|
||||
webContents: { fromId: webContentsFromIdMock },
|
||||
// The popup's own window is not the focused window.
|
||||
BrowserWindow: { fromWebContents: () => ({ isFocused: () => false }) }
|
||||
}
|
||||
})
|
||||
const { CdpWsProxyMock } = vi.hoisted(() => {
|
||||
const instances: unknown[] = []
|
||||
const MockClass = vi.fn().mockImplementation(function (
|
||||
this: Record<string, unknown>,
|
||||
_wc: unknown
|
||||
) {
|
||||
this._wc = _wc
|
||||
this.start = vi.fn(async () => 'ws://127.0.0.1:9222')
|
||||
this.stop = vi.fn(async () => {})
|
||||
this.getPort = vi.fn(() => 9222)
|
||||
instances.push(this)
|
||||
})
|
||||
return { CdpWsProxyMock: Object.assign(MockClass, { instances }) }
|
||||
})
|
||||
|
||||
vi.mock('../../cdp/transport/cdp-ws-proxy', () => ({
|
||||
CdpWsProxy: CdpWsProxyMock
|
||||
}))
|
||||
|
||||
import { AgentBrowserBridge } from '../agent-browser-bridge'
|
||||
import {
|
||||
mockBrowserManager,
|
||||
mockWebContents,
|
||||
overrideBridgeWebContentsLookup,
|
||||
resetAgentBrowserBridgeMocks,
|
||||
type MockWebContents
|
||||
} from '../testing/agent-browser-bridge-test-harness'
|
||||
|
||||
overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdMock)
|
||||
|
||||
describe('AgentBrowserBridge presses on pages outside an Orca pane', () => {
|
||||
let bridge: AgentBrowserBridge
|
||||
let wc: MockWebContents
|
||||
|
||||
beforeEach(() => {
|
||||
resetAgentBrowserBridgeMocks({
|
||||
webContentsFromIdMock,
|
||||
existsSyncMock,
|
||||
readFileSyncMock,
|
||||
stdinWrites,
|
||||
cdpWsProxyInstances: CdpWsProxyMock.instances
|
||||
})
|
||||
bridge = new AgentBrowserBridge(mockBrowserManager())
|
||||
bridge.setActiveTab(100)
|
||||
wc = mockWebContents(100)
|
||||
wc.debugger.sendCommand.mockImplementation(async (method: string) =>
|
||||
method === 'Runtime.evaluate'
|
||||
? { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } }
|
||||
: {}
|
||||
)
|
||||
webContentsFromIdMock.mockImplementation((id: number) => (id === 100 ? wc : null))
|
||||
})
|
||||
|
||||
function sentBeforePress(): string[] {
|
||||
const methods = wc.debugger.sendCommand.mock.calls.map(([method, params]) =>
|
||||
method === 'Input.dispatchMouseEvent' &&
|
||||
typeof params === 'object' &&
|
||||
params !== null &&
|
||||
'type' in params
|
||||
? `${method}:${String(params.type)}`
|
||||
: method
|
||||
)
|
||||
return methods.slice(0, methods.indexOf('Input.dispatchMouseEvent:mousePressed'))
|
||||
}
|
||||
|
||||
it.each([
|
||||
['click', () => bridge.mouseClick(10, 20, 'left', undefined, 'tab-1')],
|
||||
['mouse down', () => bridge.mouseDown('left', undefined, 'tab-1')]
|
||||
])(
|
||||
'%s on a popup whose window is not focused emulates focus instead of taking it',
|
||||
async (_name, press) => {
|
||||
await press()
|
||||
|
||||
expect(wc.focus).not.toHaveBeenCalled()
|
||||
expect(sentBeforePress()).toContain('Emulation.setFocusEmulationEnabled')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -66,54 +66,10 @@ function fakeHost(
|
||||
},
|
||||
admit: vi.fn()
|
||||
}
|
||||
const emit = (event: BrowserPageEvent): void => pageEvents.forEach((listener) => listener(event))
|
||||
return { host, tabs, emit }
|
||||
return { host }
|
||||
}
|
||||
|
||||
describe('runWithActionEffects', () => {
|
||||
it('waits for a navigation the action started and reports where it landed', async () => {
|
||||
const guest = fakeGuest()
|
||||
const appTree = '- heading "Welcome ada" [level=1, ref=e9]\n- button "Submit ticket" [ref=e10]'
|
||||
const { host } = fakeHost(guest, [UNCHANGED, { snapshot: { kind: 'full', tree: appTree } }])
|
||||
|
||||
const result = await runWithActionEffects(host, async () => {
|
||||
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
// A redirecting submit replaces its first navigation, which then fails as ERR_ABORTED.
|
||||
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
guest.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'http://127.0.0.1/submit', true)
|
||||
setTimeout(() => {
|
||||
guest.url = 'http://127.0.0.1/app.html'
|
||||
guest.title = 'App'
|
||||
guest.emit('dom-ready')
|
||||
}, 120)
|
||||
return { clicked: '@e6' }
|
||||
})
|
||||
|
||||
expect(readBrowserActionEffects(result)).toEqual({
|
||||
settled: true,
|
||||
url: 'http://127.0.0.1/app.html',
|
||||
title: 'App',
|
||||
page: { lines: appTree.split('\n'), removedLines: 0, full: true, truncated: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a new tab once it opens and a child window separately', async () => {
|
||||
const guest = fakeGuest()
|
||||
const { host, tabs, emit } = fakeHost(guest, [UNCHANGED, UNCHANGED])
|
||||
|
||||
const result = await runWithActionEffects(host, async () => {
|
||||
emit({ kind: 'popup', action: 'window', origin: 'https://accounts.example' })
|
||||
emit({ kind: 'tab', url: 'http://127.0.0.1/basic2.html' })
|
||||
setTimeout(() => tabs.set('page-2', 'http://127.0.0.1/basic2.html'), 100)
|
||||
return { clicked: '@e18' }
|
||||
})
|
||||
|
||||
expect(readBrowserActionEffects(result)).toMatchObject({
|
||||
newTabs: [{ browserPageId: 'page-2', url: 'http://127.0.0.1/basic2.html' }],
|
||||
popups: [{ action: 'window', origin: 'https://accounts.example' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not read a replacement guest the caller was never admitted to', async () => {
|
||||
const guest = fakeGuest()
|
||||
const replacement = Object.assign(fakeGuest('http://127.0.0.1/app.html', 'App'), { id: 8 })
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import { guestNavigationMethods } from '../../guest-lifecycle/manager/browser-manager-test-harness'
|
||||
import type * as WorktreeScanCacheModule from '../../../git/worktrees/catalog/worktree-scan-cache'
|
||||
import type * as WorktreeListingModule from '../../../git/worktrees/catalog/worktree-listing'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { RuntimeBrowserCommands } from '../../../runtime/browser/commands/browser-commands'
|
||||
import { setRuntimeBrowserCommandsFactory } from '../../../runtime/browser/installation/runtime-browser-commands-factory'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createConnection } from 'node:net'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BrowserManager } from '../../guest-lifecycle/manager/browser-manager'
|
||||
import { CdpBridge } from './cdp-bridge'
|
||||
import { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from '../../../runtime/rpc/server/runtime-rpc'
|
||||
import { readRuntimeMetadata } from '../../../runtime/rpc/server/runtime-metadata'
|
||||
|
||||
const { webContentsFromIdMock } = vi.hoisted(() => ({
|
||||
webContentsFromIdMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/worktrees/catalog/worktree-scan-cache', async (importOriginal) => {
|
||||
const facade = getWorktreeListingMocks()
|
||||
return {
|
||||
...(await importOriginal<typeof WorktreeScanCacheModule>()),
|
||||
listWorktrees: facade.listWorktrees
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../git/worktrees/catalog/worktree-listing', async (importOriginal) => {
|
||||
const facade = getWorktreeListingMocks()
|
||||
return {
|
||||
...(await importOriginal<typeof WorktreeListingModule>()),
|
||||
listWorktreesStrict: facade.listWorktreesStrict
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromId: webContentsFromIdMock },
|
||||
shell: { openExternal: vi.fn() },
|
||||
ipcMain: { handle: vi.fn(), removeHandler: vi.fn(), on: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}))
|
||||
|
||||
const { getWorktreeListingMocks } = vi.hoisted(() => {
|
||||
const createWorktreeListingMocks = () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([]),
|
||||
listWorktreesStrict: vi.fn().mockResolvedValue([])
|
||||
})
|
||||
let listings: ReturnType<typeof createWorktreeListingMocks> | undefined
|
||||
return { getWorktreeListingMocks: () => (listings ??= createWorktreeListingMocks()) }
|
||||
})
|
||||
|
||||
type AXNode = {
|
||||
nodeId: string
|
||||
backendDOMNodeId?: number
|
||||
role?: { type: string; value: string }
|
||||
name?: { type: string; value: string }
|
||||
properties?: { name: string; value: { type: string; value: unknown } }[]
|
||||
childIds?: string[]
|
||||
ignored?: boolean
|
||||
}
|
||||
|
||||
function axNode(
|
||||
id: string,
|
||||
role: string,
|
||||
name: string,
|
||||
opts?: { childIds?: string[]; backendDOMNodeId?: number }
|
||||
): AXNode {
|
||||
return {
|
||||
nodeId: id,
|
||||
backendDOMNodeId: opts?.backendDOMNodeId ?? Number.parseInt(id, 10) * 100,
|
||||
role: { type: 'role', value: role },
|
||||
name: { type: 'computedString', value: name },
|
||||
childIds: opts?.childIds
|
||||
}
|
||||
}
|
||||
|
||||
const EXAMPLE_COM_TREE: AXNode[] = [
|
||||
axNode('1', 'WebArea', 'Example Domain', { childIds: ['2', '3', '4'] }),
|
||||
axNode('2', 'heading', 'Example Domain'),
|
||||
axNode('3', 'staticText', 'This domain is for use in illustrative examples.'),
|
||||
axNode('4', 'link', 'More information...', { backendDOMNodeId: 400 })
|
||||
]
|
||||
|
||||
const SEARCH_PAGE_TREE: AXNode[] = [
|
||||
axNode('1', 'WebArea', 'Search', { childIds: ['2', '3', '4', '5'] }),
|
||||
axNode('2', 'navigation', 'Main Nav', { childIds: ['3'] }),
|
||||
axNode('3', 'link', 'Home', { backendDOMNodeId: 300 }),
|
||||
axNode('4', 'textbox', 'Search query', { backendDOMNodeId: 400 }),
|
||||
axNode('5', 'button', 'Search', { backendDOMNodeId: 500 })
|
||||
]
|
||||
|
||||
function createMockGuest(
|
||||
id: number,
|
||||
url: string,
|
||||
title: string,
|
||||
options?: { readyState?: string | (() => string) }
|
||||
) {
|
||||
let currentUrl = url
|
||||
let currentTitle = title
|
||||
let currentTree = EXAMPLE_COM_TREE
|
||||
let navHistoryId = 1
|
||||
let debuggerAttached = false
|
||||
|
||||
const sendCommandMock = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
switch (method) {
|
||||
case 'Page.enable':
|
||||
case 'DOM.enable':
|
||||
case 'Accessibility.enable':
|
||||
return {}
|
||||
case 'Accessibility.getFullAXTree':
|
||||
return { nodes: currentTree }
|
||||
case 'Page.getNavigationHistory':
|
||||
return {
|
||||
entries: [{ id: navHistoryId, url: currentUrl }],
|
||||
currentIndex: 0
|
||||
}
|
||||
case 'Page.navigate': {
|
||||
const targetUrl = (params as { url: string }).url
|
||||
if (targetUrl.includes('nonexistent.invalid')) {
|
||||
return { errorText: 'net::ERR_NAME_NOT_RESOLVED' }
|
||||
}
|
||||
navHistoryId++
|
||||
currentUrl = targetUrl
|
||||
if (targetUrl.includes('search.example.com')) {
|
||||
currentTitle = 'Search'
|
||||
currentTree = SEARCH_PAGE_TREE
|
||||
} else {
|
||||
currentTitle = 'Example Domain'
|
||||
currentTree = EXAMPLE_COM_TREE
|
||||
}
|
||||
return {}
|
||||
}
|
||||
case 'Runtime.evaluate': {
|
||||
const expr = (params as { expression: string }).expression
|
||||
if (expr === 'document.readyState') {
|
||||
return {
|
||||
result: {
|
||||
value:
|
||||
typeof options?.readyState === 'function'
|
||||
? options.readyState()
|
||||
: (options?.readyState ?? 'complete')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expr === 'location.origin') {
|
||||
return { result: { value: new URL(currentUrl).origin } }
|
||||
}
|
||||
if (expr.includes('innerWidth')) {
|
||||
return { result: { value: JSON.stringify({ w: 1280, h: 720 }) } }
|
||||
}
|
||||
if (expr.includes('scrollBy')) {
|
||||
return { result: { value: undefined } }
|
||||
}
|
||||
if (expr.includes('dispatchEvent')) {
|
||||
return { result: { value: undefined } }
|
||||
}
|
||||
// eslint-disable-next-line no-eval
|
||||
return { result: { value: String(eval(expr)), type: 'string' } }
|
||||
}
|
||||
case 'DOM.scrollIntoViewIfNeeded':
|
||||
return {}
|
||||
case 'DOM.getBoxModel':
|
||||
return { model: { content: [100, 200, 300, 200, 300, 250, 100, 250] } }
|
||||
case 'Input.dispatchMouseEvent':
|
||||
return {}
|
||||
case 'Input.insertText':
|
||||
return {}
|
||||
case 'Input.dispatchKeyEvent':
|
||||
return {}
|
||||
case 'DOM.focus':
|
||||
return {}
|
||||
case 'DOM.describeNode':
|
||||
return { node: { nodeId: 1 } }
|
||||
case 'DOM.requestNode':
|
||||
return { nodeId: 1 }
|
||||
case 'DOM.resolveNode':
|
||||
return { object: { objectId: 'obj-1' } }
|
||||
case 'Runtime.callFunctionOn':
|
||||
return { result: { value: undefined } }
|
||||
case 'DOM.setFileInputFiles':
|
||||
return {}
|
||||
case 'Page.captureScreenshot':
|
||||
return {
|
||||
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
}
|
||||
case 'Page.reload':
|
||||
return {}
|
||||
case 'Network.enable':
|
||||
return {}
|
||||
case 'Target.setAutoAttach':
|
||||
return {}
|
||||
case 'Page.addScriptToEvaluateOnNewDocument':
|
||||
return { identifier: 'mock-script-id' }
|
||||
case 'Runtime.enable':
|
||||
return {}
|
||||
default:
|
||||
throw new Error(`Unexpected CDP method: ${method}`)
|
||||
}
|
||||
})
|
||||
|
||||
const debuggerListeners = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
|
||||
const guest = {
|
||||
...guestNavigationMethods(),
|
||||
id,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
setIgnoreMenuShortcuts: vi.fn(),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
getURL: vi.fn(() => currentUrl),
|
||||
getTitle: vi.fn(() => currentTitle),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => debuggerAttached),
|
||||
attach: vi.fn(() => {
|
||||
if (debuggerAttached) {
|
||||
throw new Error('Another debugger is already attached')
|
||||
}
|
||||
debuggerAttached = true
|
||||
}),
|
||||
detach: vi.fn(),
|
||||
sendCommand: sendCommandMock,
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = debuggerListeners.get(event) ?? []
|
||||
handlers.push(handler)
|
||||
debuggerListeners.set(event, handlers)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = debuggerListeners.get(event) ?? []
|
||||
const idx = handlers.indexOf(handler)
|
||||
if (idx !== -1) {
|
||||
handlers.splice(idx, 1)
|
||||
}
|
||||
}),
|
||||
removeAllListeners: vi.fn((event: string) => {
|
||||
debuggerListeners.set(event, [])
|
||||
}),
|
||||
off: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
guest,
|
||||
sendCommandMock,
|
||||
setTree(tree: AXNode[]) {
|
||||
currentTree = tree
|
||||
},
|
||||
emitDebugger(event: string, ...args: unknown[]) {
|
||||
for (const handler of debuggerListeners.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
},
|
||||
emitDebuggerMessage(method: string, params?: Record<string, unknown>) {
|
||||
for (const handler of debuggerListeners.get('message') ?? []) {
|
||||
handler({}, method, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequest(
|
||||
endpoint: string,
|
||||
request: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = createConnection(endpoint)
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', reject)
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
const newlineIndex = buffer.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
const message = buffer.slice(0, newlineIndex)
|
||||
socket.end()
|
||||
resolve(JSON.parse(message) as Record<string, unknown>)
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
socket.write(`${JSON.stringify(request)}\n`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('Browser automation pipeline (integration)', () => {
|
||||
let server: OrcaRuntimeRpcServer
|
||||
let endpoint: string
|
||||
let authToken: string
|
||||
let activeGuestHarness: ReturnType<typeof createMockGuest>
|
||||
let cdpBridge: CdpBridge
|
||||
|
||||
const GUEST_WC_ID = 5001
|
||||
const RENDERER_WC_ID = 1
|
||||
|
||||
beforeEach(async () => {
|
||||
// The Electron entry installs this factory; Node hosts omit browser commands.
|
||||
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
|
||||
activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain')
|
||||
const { guest } = activeGuestHarness
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === GUEST_WC_ID) {
|
||||
return guest
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const browserManager = new BrowserManager()
|
||||
// Production applies these policies in will-attach-webview.
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-1',
|
||||
webContentsId: GUEST_WC_ID,
|
||||
rendererWebContentsId: RENDERER_WC_ID
|
||||
})
|
||||
|
||||
cdpBridge = new CdpBridge(browserManager)
|
||||
cdpBridge.setActiveTab(GUEST_WC_ID)
|
||||
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'browser-e2e-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
runtime.setAgentBrowserBridge(cdpBridge as any)
|
||||
|
||||
server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)!
|
||||
endpoint = metadata.transports[0]!.endpoint
|
||||
authToken = metadata.authToken!
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
async function rpc(method: string, params?: Record<string, unknown>) {
|
||||
const response = await sendRequest(endpoint, {
|
||||
id: `req_${method}`,
|
||||
authToken,
|
||||
method,
|
||||
...(params ? { params } : {})
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
it('navigates to a URL and invalidates refs', async () => {
|
||||
await rpc('browser.snapshot')
|
||||
|
||||
const gotoRes = await rpc('browser.goto', { url: 'https://search.example.com' })
|
||||
expect(gotoRes.ok).toBe(true)
|
||||
const gotoResult = gotoRes.result as { url: string; title: string }
|
||||
expect(gotoResult.url).toBe('https://search.example.com')
|
||||
expect(gotoResult.title).toBe('Search')
|
||||
|
||||
const clickRes = await rpc('browser.click', { element: '@e1' })
|
||||
expect(clickRes.ok).toBe(false)
|
||||
expect((clickRes.error as { code: string }).code).toBe('browser_stale_ref')
|
||||
|
||||
const snapRes = await rpc('browser.snapshot')
|
||||
expect(snapRes.ok).toBe(true)
|
||||
const snapResult = snapRes.result as { snapshot: string; refs: { name: string }[] }
|
||||
expect(snapResult.snapshot).toContain('Search')
|
||||
expect(snapResult.refs.map((r) => r.name)).toContain('Search')
|
||||
expect(snapResult.refs.map((r) => r.name)).toContain('Home')
|
||||
})
|
||||
})
|
||||
@@ -7,17 +7,6 @@ describe('CdpPdfStreamStore', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('claims only its own handles', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('pdf'))
|
||||
|
||||
expect(store.ownsHandle({ handle })).toBe(true)
|
||||
expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/)
|
||||
expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false)
|
||||
expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false)
|
||||
expect(store.ownsHandle({})).toBe(false)
|
||||
})
|
||||
|
||||
it('reads sequential chunks and reports eof', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
@@ -32,23 +21,6 @@ describe('CdpPdfStreamStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('honors an explicit read offset', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
|
||||
expect(store.read({ handle, offset: 4 })).toEqual({
|
||||
data: Buffer.from('ef').toString('base64'),
|
||||
eof: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty eof chunk when reading past the end', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true })
|
||||
})
|
||||
|
||||
it('evicts an abandoned stream after the TTL', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = new CdpPdfStreamStore()
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { splitBrowserTextInsertionChunks } from '../../../../shared/browser-text-insertion-chunks'
|
||||
|
||||
describe('browser text insertion chunking', () => {
|
||||
it('keeps a single multibyte character intact when the byte cap is smaller', () => {
|
||||
const chunks = splitBrowserTextInsertionChunks('😀a', 1)
|
||||
|
||||
expect(chunks).toEqual(['😀', 'a'])
|
||||
expect(chunks.join('')).toBe('😀a')
|
||||
})
|
||||
})
|
||||
@@ -116,6 +116,10 @@ export class CdpPageInputCommands {
|
||||
return await dispatchPageKeyEvent(this.webContents, params, this.sendRoot, this.guardFocus)
|
||||
}
|
||||
if (!isEmbeddedGuest(this.webContents)) {
|
||||
// Popup and serve pages never borrow native focus, so emulate it as the bridge does.
|
||||
if (isFocusTakingCdpInput(method, params)) {
|
||||
await enablePageFocusEmulation(this.sendRoot)
|
||||
}
|
||||
return await this.debuggerChannel.sendDebuggerCommand(method, params, sessionId)
|
||||
}
|
||||
const send = (): Promise<unknown> =>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startBrowserScreencast } from './browser-screencast-stream'
|
||||
import type { BrowserScreencastOptions } from './browser-screencast-stream-types'
|
||||
import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double'
|
||||
|
||||
function start(
|
||||
webContents: ReturnType<typeof createMockScreencastWebContents>,
|
||||
options: BrowserScreencastOptions
|
||||
) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream touches only the debugger, isDestroyed and capturePage the doubles provide.
|
||||
return startBrowserScreencast(webContents as never, options)
|
||||
}
|
||||
|
||||
function startOptions(viewport: boolean) {
|
||||
return {
|
||||
format: 'jpeg' as const,
|
||||
quality: 70,
|
||||
maxWidth: 1440,
|
||||
maxHeight: 1200,
|
||||
everyNthFrame: 2,
|
||||
minFrameIntervalMs: 0,
|
||||
holdPaint: () => () => {},
|
||||
...(viewport ? { viewportWidth: 390, viewportHeight: 844 } : {}),
|
||||
onFrame: vi.fn(() => true),
|
||||
onError: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser screencast first-frame deadline', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drops a capture that settles after the limit', async () => {
|
||||
let settleCapture!: (image: unknown) => void
|
||||
const webContents = Object.assign(createMockScreencastWebContents(), {
|
||||
capturePage: vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settleCapture = resolve
|
||||
})
|
||||
)
|
||||
})
|
||||
const options = startOptions(true)
|
||||
const session = await start(webContents, options)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
settleCapture({
|
||||
getSize: () => ({ width: 390, height: 844 }),
|
||||
toJPEG: () => Buffer.from('late'),
|
||||
toPNG: () => Buffer.from('late')
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(options.onFrame).not.toHaveBeenCalled()
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { decodeBrowserScreencastFrame } from '../../../../shared/browser/screencast/browser-screencast-protocol'
|
||||
|
||||
import { startBrowserScreencast } from './browser-screencast-stream'
|
||||
import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double'
|
||||
|
||||
@@ -76,55 +74,6 @@ async function startOversizedStream(guest: ReturnType<typeof createGuest>, onFra
|
||||
}
|
||||
|
||||
describe('screencast of a guest smaller than the requested viewport (#30341)', () => {
|
||||
it('re-emulates at the size the guest can paint and labels frames with it', async () => {
|
||||
const guest = createGuest('webview')
|
||||
const onFrame = vi.fn()
|
||||
const session = await startOversizedStream(guest, onFrame)
|
||||
await vi.waitFor(() =>
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'Page.captureScreenshot',
|
||||
expect.anything()
|
||||
)
|
||||
)
|
||||
expect(emulatedSizes(guest)).toEqual(['1833x1179'])
|
||||
|
||||
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() => expect(emulatedSizes(guest)).toEqual(['1833x1179', '1463x849']))
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', {
|
||||
sessionId: 1
|
||||
})
|
||||
expect(onFrame).not.toHaveBeenCalled()
|
||||
|
||||
emitGuestFrame(guest, 2, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() => expect(onFrame).toHaveBeenCalledTimes(1))
|
||||
expect(decodeBrowserScreencastFrame(onFrame.mock.calls[0][0])?.metadata).toMatchObject({
|
||||
deviceWidth: 1463,
|
||||
deviceHeight: 849
|
||||
})
|
||||
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
|
||||
it('gives the requested size back once the guest grows, and follows a new request', async () => {
|
||||
const guest = createGuest('webview')
|
||||
const session = await startOversizedStream(guest)
|
||||
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() => expect(emulatedSizes(guest)).toHaveLength(2))
|
||||
|
||||
emitGuestFrame(guest, 2, { width: 2000, height: 1300 })
|
||||
await vi.waitFor(() => expect(emulatedSizes(guest).at(-1)).toBe('1833x1179'))
|
||||
|
||||
await session.updateViewport({ viewportWidth: 1028, viewportHeight: 1179 })
|
||||
expect(emulatedSizes(guest).at(-1)).toBe('1028x1179')
|
||||
|
||||
emitGuestFrame(guest, 3, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() => expect(emulatedSizes(guest).at(-1)).toBe('1028x849'))
|
||||
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
|
||||
it('keeps dropping guest frames until the fitted size is actually emulated', async () => {
|
||||
const guest = createGuest('webview')
|
||||
let releaseSnapshot!: () => void
|
||||
@@ -183,20 +132,4 @@ describe('screencast of a guest smaller than the requested viewport (#30341)', (
|
||||
|
||||
session.stop()
|
||||
})
|
||||
|
||||
it('keeps the full request for a window guest, which can grow to it', async () => {
|
||||
const guest = createGuest('window')
|
||||
const session = await startOversizedStream(guest)
|
||||
|
||||
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() =>
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', {
|
||||
sessionId: 1
|
||||
})
|
||||
)
|
||||
expect(emulatedSizes(guest)).toEqual(['1833x1179'])
|
||||
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,15 +142,6 @@ describe('BrowserCertificateTrustController', () => {
|
||||
controller.installSessionRequestGuard(browserSession)
|
||||
})
|
||||
|
||||
it('keeps challenge ownership and expiry when reading a specific guest', () => {
|
||||
certificateEvent({ controller, guest })
|
||||
expect(controller.hasFailureForGuest(guest.id)).toBe(true)
|
||||
expect(controller.hasFailureForGuest(otherGuest.id)).toBe(false)
|
||||
now += 300_000
|
||||
expect(controller.hasFailureForGuest(guest.id)).toBe(false)
|
||||
expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: false, reason: 'missing' })
|
||||
})
|
||||
|
||||
it('blocks a session-cached certificate for sibling requests until that guest approves it', () => {
|
||||
certificateEvent({ controller, guest })
|
||||
expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true })
|
||||
|
||||
+3
-53
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol'
|
||||
import { BrowserClientPageCommandExecutor } from '../execution/browser-client-page-command-executor'
|
||||
import {
|
||||
@@ -11,21 +11,17 @@ import type { BrowserRouteElectronSession } from '../../routing/sessions/browser
|
||||
import type { BrowserRoutePageGuestIdentity } from '../../routing/guest-authority/browser-route-page-authority'
|
||||
|
||||
const SSH_HOST_STORAGE = 'storage-ssh-target-a'
|
||||
const OTHER_HOST_STORAGE = 'storage-ssh-target-b'
|
||||
const GENERATION_1 = 'execution-host-ssh-a-gen-1'
|
||||
const GENERATION_2 = 'execution-host-ssh-a-gen-2'
|
||||
const OTHER_HOST = 'execution-host-ssh-b-gen-1'
|
||||
|
||||
const proxyPortByKey: Record<string, number> = {
|
||||
[GENERATION_1]: 43123,
|
||||
[GENERATION_2]: 43124,
|
||||
[OTHER_HOST]: 43125
|
||||
[GENERATION_2]: 43124
|
||||
}
|
||||
|
||||
const storageIdentityByKey: Record<string, string> = {
|
||||
[GENERATION_1]: SSH_HOST_STORAGE,
|
||||
[GENERATION_2]: SSH_HOST_STORAGE,
|
||||
[OTHER_HOST]: OTHER_HOST_STORAGE
|
||||
[GENERATION_2]: SSH_HOST_STORAGE
|
||||
}
|
||||
|
||||
function createHarness(options: { failedRouteRelease?: string } = {}) {
|
||||
@@ -142,52 +138,6 @@ function createPage(
|
||||
)
|
||||
}
|
||||
|
||||
describe('client-hosted pages across an execution host generation change', () => {
|
||||
it('retires the superseded pages so the reconnected route can bind the partition', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
expect(await createPage(harness.executor, 'page-a', GENERATION_1)).toEqual({
|
||||
status: 'completed'
|
||||
})
|
||||
expect(await createPage(harness.executor, 'page-b', GENERATION_2)).toEqual({
|
||||
status: 'completed'
|
||||
})
|
||||
|
||||
expect(harness.executor.hasPage('page-a', 7)).toBe(false)
|
||||
expect(harness.executor.hasPage('page-b', 7)).toBe(true)
|
||||
expect(harness.releasedRoutes).toEqual([GENERATION_1])
|
||||
expect(harness.proxyRulesFor(SSH_HOST_STORAGE)).toBe('socks5://127.0.0.1:43124')
|
||||
})
|
||||
|
||||
it('keeps the sibling pages that share the current route key', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await createPage(harness.executor, 'page-a', GENERATION_1)
|
||||
expect(await createPage(harness.executor, 'page-b', GENERATION_1)).toEqual({
|
||||
status: 'completed'
|
||||
})
|
||||
|
||||
expect(harness.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(harness.executor.hasPage('page-b', 7)).toBe(true)
|
||||
expect(harness.releasedRoutes).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the pages held on a different execution host', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await createPage(harness.executor, 'page-a', OTHER_HOST)
|
||||
expect(await createPage(harness.executor, 'page-b', GENERATION_2)).toEqual({
|
||||
status: 'completed'
|
||||
})
|
||||
|
||||
expect(harness.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(harness.executor.hasPage('page-b', 7)).toBe(true)
|
||||
expect(harness.releasedRoutes).toEqual([])
|
||||
expect(harness.proxyRulesFor(OTHER_HOST_STORAGE)).toBe('socks5://127.0.0.1:43125')
|
||||
expect(harness.proxyRulesFor(SSH_HOST_STORAGE)).toBe('socks5://127.0.0.1:43124')
|
||||
})
|
||||
})
|
||||
|
||||
it('allows a different legacy page to retarget only after exact native and network retirement despite a missing renderer acknowledgement', async () => {
|
||||
const h = createHarness()
|
||||
const destroyed = Promise.withResolvers<void>()
|
||||
|
||||
@@ -51,26 +51,6 @@ async function fixture(
|
||||
}
|
||||
|
||||
describe('staged client page profile switching', () => {
|
||||
it('finishes failed old-generation renderer cleanup on teardown without repeating its releases', async () => {
|
||||
const h = await fixture()
|
||||
h.renderer.retirePage.mockRejectedValueOnce(new Error('old-retirement-reply-lost'))
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'completed'
|
||||
})
|
||||
expect(h.executor.snapshotPageInventory()).toMatchObject([
|
||||
{ pageHostGeneration: 8, state: 'active' }
|
||||
])
|
||||
expect(h.routeSession.release).toHaveBeenCalledTimes(1)
|
||||
await h.executor.close()
|
||||
expect(h.renderer.retirePage).toHaveBeenCalledTimes(3)
|
||||
expect(h.routeSession.release).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
warning.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('finishes independent pages, retained profile cleanup and uploads after one initializer cleanup fails', async () => {
|
||||
const h = await fixture()
|
||||
const signal = new AbortController().signal
|
||||
|
||||
@@ -81,33 +81,4 @@ describe('readDocPreviewFile — paired runtime owner', () => {
|
||||
status: 404
|
||||
})
|
||||
})
|
||||
|
||||
it('requires approval for a sibling directory before touching the runtime', async () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'env-1',
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/srv/repo'
|
||||
},
|
||||
requestBase: '/srv/repo',
|
||||
root: '/srv/repo/docs',
|
||||
entryRelativePath: 'docs/index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: 403,
|
||||
reason: 'authorization-required'
|
||||
})
|
||||
|
||||
authorizeDocPreviewDirectory(grant.id, 'assets/app.js')
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: 'console.log(1)', isBinary: false }
|
||||
})
|
||||
|
||||
await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readDocPreviewFile } from './doc-preview-file-reader'
|
||||
import {
|
||||
authorizeDocPreviewDirectory,
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
|
||||
vi.mock('../../ipc/runtime-environments/requests/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.call
|
||||
}))
|
||||
vi.mock('../../ipc/runtime-environments/catalog/runtime-environment-registry-root', () => ({
|
||||
getRuntimeEnvironmentRegistryRoot: () => '/user-data'
|
||||
}))
|
||||
vi.mock('../../../shared/remote-runtime/host-store/runtime-environment-store', () => ({
|
||||
resolveEnvironment: () => ({ id: 'vps', createdAt: 1, pairingRevision: 12 })
|
||||
}))
|
||||
|
||||
function grant(root = '/tmp/reports'): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'vps',
|
||||
worktreeSelector: 'id:source-workspace',
|
||||
worktreeRoot: '/srv/repo',
|
||||
fileAccess: 'terminal-artifact'
|
||||
},
|
||||
requestBase: root,
|
||||
root,
|
||||
entryRelativePath: 'report.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
type HostEntry = { canonical?: string; openTarget?: Record<string, unknown> }
|
||||
|
||||
/** The paired host: resolveTerminalPath answers per path, reads answer with `read`. */
|
||||
function host(entries: Record<string, HostEntry>, read?: unknown): void {
|
||||
mocks.call.mockImplementation(
|
||||
async (_root: string, _env: string, method: string, params: { pathText?: string }) => {
|
||||
if (method !== 'files.resolveTerminalPath') {
|
||||
return read
|
||||
}
|
||||
const path = params.pathText ?? ''
|
||||
const entry = entries[path] ?? {}
|
||||
const canonical = entry.canonical ?? path
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
absolutePath: canonical,
|
||||
openTarget: entry.openTarget ?? {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: canonical,
|
||||
grantId: 'host-grant'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
describe('outside-workspace HTML preview', () => {
|
||||
it('does not resolve or read neighboring assets until their directory is approved', async () => {
|
||||
const page = grant()
|
||||
await expect(readDocPreviewFile(page, 'secret.json')).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: 403,
|
||||
reason: 'authorization-required'
|
||||
})
|
||||
|
||||
authorizeDocPreviewDirectory(page.id, 'assets/logo.png')
|
||||
host(
|
||||
{},
|
||||
{
|
||||
ok: true,
|
||||
result: {
|
||||
content: Buffer.from([137, 80, 78, 71]).toString('base64'),
|
||||
isBinary: true,
|
||||
mimeType: 'image/png'
|
||||
}
|
||||
}
|
||||
)
|
||||
await expect(readDocPreviewFile(page, 'assets/logo.png')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
bytes: Buffer.from([137, 80, 78, 71]),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses symlink redirection outside an approved asset directory', async () => {
|
||||
const page = grant()
|
||||
authorizeDocPreviewDirectory(page.id, 'assets/data.json')
|
||||
host({ '/tmp/reports/assets/data.json': { canonical: '/home/dev/.config/credentials.json' } })
|
||||
|
||||
await expect(readDocPreviewFile(page, 'assets/data.json')).resolves.toMatchObject({
|
||||
ok: false
|
||||
})
|
||||
})
|
||||
|
||||
// The host canonicalizes these roots, so a nested symlink cannot leave the approved folder.
|
||||
})
|
||||
-29
@@ -11,7 +11,6 @@ const input = {
|
||||
control: process.platform !== 'darwin',
|
||||
shift: true
|
||||
}
|
||||
const primaryDoubleTap = process.platform === 'darwin' ? 'Cmd' : 'Ctrl'
|
||||
|
||||
function fixture(local = true) {
|
||||
const send = vi.fn()
|
||||
@@ -120,34 +119,6 @@ describe('Plugins shortcut from a browser guest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('follows the typed letter on a German layout where Y and Z swap keys on Linux', () => {
|
||||
withPlatform('linux', () => {
|
||||
const { context, event, send } = fixture()
|
||||
context.getKeybindings = () => ({})
|
||||
const chord = { control: true, shift: true }
|
||||
expect(forwardGuestShortcutInput(context, event, { ...chord, key: 'Z', code: 'KeyY' })).toBe(
|
||||
false
|
||||
)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(forwardGuestShortcutInput(context, event, { ...chord, key: 'Y', code: 'KeyZ' })).toBe(
|
||||
true
|
||||
)
|
||||
expect(send).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('replays a configured modifier double tap', () => {
|
||||
const { context, event, send } = fixture()
|
||||
context.getKeybindings = () => ({ 'view.plugins': ['DoubleTap+Mod'] })
|
||||
expect(forwardGuestShortcutInput(context, event, { doubleTapModifier: primaryDoubleTap })).toBe(
|
||||
true
|
||||
)
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'ui:browserPageTabShortcut',
|
||||
expect.objectContaining({ doubleTapModifier: primaryDoubleTap })
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves composing input and nonlocal renderers alone', () => {
|
||||
const { context, event, send } = fixture(false)
|
||||
expect(forwardGuestShortcutInput(context, event, input)).toBe(false)
|
||||
|
||||
-71
@@ -43,7 +43,6 @@ vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({
|
||||
|
||||
import { browserManager } from '../browser-manager'
|
||||
import { respondToBrowserPagePrompt } from '../../prompts/browser-page-prompts'
|
||||
import { handleBrowserGuestLogin } from '../../prompts/browser-http-auth-prompt'
|
||||
import {
|
||||
guestNavigationMethods,
|
||||
rendererWebContentsId,
|
||||
@@ -159,74 +158,4 @@ describe('browserManager page prompt routing', () => {
|
||||
expect(prompts().at(-1)).toMatchObject({ origin: null })
|
||||
answerLatest(false)
|
||||
})
|
||||
|
||||
it("asks for a new tab's first sign-in once its window registers the tab", async () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
...guestNavigationMethods(),
|
||||
id: 109,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
getURL: vi.fn(() => ''),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
prependListener: vi.fn(),
|
||||
once: vi.fn(),
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: () => false,
|
||||
send: rendererSendMock,
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) =>
|
||||
id === guest.id ? guest : id === rendererWebContentsId ? renderer : null
|
||||
)
|
||||
// Guests can start loading before renderer registration.
|
||||
browserManager.attachGuestPolicies(webContentsFromIdMock(guest.id))
|
||||
const event = { preventDefault: vi.fn() }
|
||||
const callback = vi.fn()
|
||||
handleBrowserGuestLogin(
|
||||
event,
|
||||
webContentsFromIdMock(guest.id),
|
||||
{
|
||||
url: 'https://staging.acme.com/',
|
||||
pid: 1,
|
||||
isRequestForNavigation: true,
|
||||
firstAuthAttempt: true
|
||||
},
|
||||
{ isProxy: false, scheme: 'basic', host: 'staging.acme.com', port: 443, realm: 'Staging' },
|
||||
callback
|
||||
)
|
||||
// Keep the challenge pending until registration supplies a prompt window.
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(rendererSendMock).not.toHaveBeenCalledWith(
|
||||
'browser:page-prompt-requested',
|
||||
expect.anything()
|
||||
)
|
||||
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-3',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const request = rendererSendMock.mock.calls.find(
|
||||
([channel]) => channel === 'browser:page-prompt-requested'
|
||||
)?.[1]
|
||||
expect(request).toMatchObject({
|
||||
kind: 'http-auth',
|
||||
details: { origin: 'https://staging.acme.com' }
|
||||
})
|
||||
await respondToBrowserPagePrompt(webContentsFromIdMock(rendererWebContentsId), {
|
||||
requestId: request.requestId,
|
||||
answer: { username: 'qa', password: 'hunter2' }
|
||||
})
|
||||
expect(callback).toHaveBeenCalledWith('qa', 'hunter2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createPageInitiatedTabBudget,
|
||||
MAX_PAGE_INITIATED_TABS_PER_WINDOW,
|
||||
PAGE_INITIATED_TAB_WINDOW_MS
|
||||
} from './browser-page-initiated-tab-budget'
|
||||
import { createPageInitiatedTabBudget } from './browser-page-initiated-tab-budget'
|
||||
|
||||
describe('page-initiated tab budget', () => {
|
||||
it('refills once the rolling window has passed, so real browsing is unaffected', () => {
|
||||
const budget = createPageInitiatedTabBudget()
|
||||
for (let i = 0; i < MAX_PAGE_INITIATED_TABS_PER_WINDOW; i++) {
|
||||
expect(budget.tryConsume(1_000)).toBe(true)
|
||||
}
|
||||
expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS - 1)).toBe(false)
|
||||
expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('slides rather than resetting, so a paced flood cannot outrun the cap', () => {
|
||||
const budget = createPageInitiatedTabBudget(2, 1_000)
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolvePagePromptTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../manager/browser-manager', () => ({
|
||||
browserManager: {
|
||||
resolvePagePromptTarget: mocks.resolvePagePromptTarget
|
||||
}
|
||||
}))
|
||||
|
||||
import type { BrowserPagePromptRequest } from '../../../../shared/browser/prompts/browser-page-prompt'
|
||||
import { handleBrowserGuestLogin } from './browser-http-auth-prompt'
|
||||
import { BROWSER_PAGE_PROMPT_TIMEOUT_MS, respondToBrowserPagePrompt } from './browser-page-prompts'
|
||||
|
||||
type MockGuest = Electron.WebContents & EventEmitter
|
||||
|
||||
const guests: MockGuest[] = []
|
||||
|
||||
function mockGuest(pageUrl = 'https://staging.acme.com/'): MockGuest {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prompt only reads id, isDestroyed, getURL and the input, navigation and destroyed events.
|
||||
const guest = new EventEmitter() as MockGuest
|
||||
Object.assign(guest, {
|
||||
id: 100 + guests.length,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => pageUrl
|
||||
})
|
||||
guests.push(guest)
|
||||
return guest
|
||||
}
|
||||
|
||||
function mockRenderer(id: number): Electron.WebContents {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: prompts only read id, isDestroyed, send and subscribe to the window's lifecycle events.
|
||||
return {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
} as unknown as Electron.WebContents
|
||||
}
|
||||
|
||||
function showTabIn(renderer: Electron.WebContents, browserPageId = 'page-1'): void {
|
||||
mocks.resolvePagePromptTarget.mockReturnValue({ browserPageId, renderer })
|
||||
}
|
||||
|
||||
function challenge(
|
||||
guest: MockGuest,
|
||||
{
|
||||
url = 'https://staging.acme.com/',
|
||||
isRequestForNavigation = true,
|
||||
firstAuthAttempt = true,
|
||||
isProxy = false,
|
||||
realm = 'Staging'
|
||||
} = {}
|
||||
) {
|
||||
const event = { preventDefault: vi.fn() }
|
||||
const callback = vi.fn()
|
||||
handleBrowserGuestLogin(
|
||||
event,
|
||||
guest,
|
||||
{ url, pid: 1, isRequestForNavigation, firstAuthAttempt },
|
||||
{ isProxy, scheme: 'basic', host: new URL(url).hostname, port: 443, realm },
|
||||
callback
|
||||
)
|
||||
return { asked: event.preventDefault.mock.calls.length > 0, callback }
|
||||
}
|
||||
|
||||
function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest<'http-auth'>[] {
|
||||
return vi
|
||||
.mocked(renderer.send)
|
||||
.mock.calls.filter(([channel]) => channel === 'browser:page-prompt-requested')
|
||||
.map(([, request]) => request)
|
||||
}
|
||||
|
||||
const answer = (renderer: Electron.WebContents, requestId: string, value: unknown) =>
|
||||
respondToBrowserPagePrompt(renderer, { requestId, answer: value })
|
||||
|
||||
const CREDENTIALS = { username: 'qa', password: 'hunter2' }
|
||||
|
||||
describe('browser HTTP auth prompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.resolvePagePromptTarget.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS)
|
||||
for (const guest of guests.splice(0)) {
|
||||
guest.emit('destroyed')
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a proxy challenge', { isProxy: true }],
|
||||
[
|
||||
'an image from another site',
|
||||
{ url: 'https://cdn.other.com/a.png', isRequestForNavigation: false }
|
||||
]
|
||||
])('leaves %s to Electron, which cancels it', (_name, options) => {
|
||||
const renderer = mockRenderer(10)
|
||||
showTabIn(renderer)
|
||||
|
||||
const { asked } = challenge(mockGuest(), options)
|
||||
|
||||
expect(asked).toBe(false)
|
||||
expect(sentRequests(renderer)).toEqual([])
|
||||
})
|
||||
|
||||
it('shares one prompt per realm and cancels a different challenge meanwhile', async () => {
|
||||
const renderer = mockRenderer(10)
|
||||
showTabIn(renderer)
|
||||
const guest = mockGuest()
|
||||
|
||||
const first = challenge(guest)
|
||||
const sameRealm = challenge(guest, {
|
||||
url: 'https://staging.acme.com/logo.png',
|
||||
isRequestForNavigation: false
|
||||
})
|
||||
const otherRealm = challenge(guest, { realm: 'Admin' })
|
||||
|
||||
expect([first.asked, sameRealm.asked, otherRealm.asked]).toEqual([true, true, false])
|
||||
const [request] = sentRequests(renderer)
|
||||
expect(sentRequests(renderer)).toHaveLength(1)
|
||||
await answer(renderer, request.requestId, CREDENTIALS)
|
||||
expect(first.callback).toHaveBeenCalledWith('qa', 'hunter2')
|
||||
expect(sameRealm.callback).toHaveBeenCalledWith('qa', 'hunter2')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getBrowserJavaScriptDialog,
|
||||
installBrowserJavaScriptDialogs
|
||||
} from './browser-javascript-dialogs'
|
||||
import { respondToBrowserPagePrompt } from './browser-page-prompts'
|
||||
|
||||
type MockContents = Electron.WebContents & EventEmitter & { debugger: EventEmitter }
|
||||
|
||||
@@ -58,39 +57,6 @@ function setup() {
|
||||
}
|
||||
|
||||
describe('browser JavaScript dialogs', () => {
|
||||
it('asks in the page prompt instead of Electron’s window-wide sheet', () => {
|
||||
const { guest, electronSheet, openDialog, shownPrompts } = setup()
|
||||
|
||||
const callback = openDialog('confirm', 'Delete everything?')
|
||||
|
||||
expect(electronSheet).not.toHaveBeenCalled()
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(getBrowserJavaScriptDialog(guest.id)).toEqual({
|
||||
type: 'confirm',
|
||||
message: 'Delete everything?',
|
||||
defaultPromptText: ''
|
||||
})
|
||||
expect(shownPrompts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'javascript-dialog',
|
||||
details: {
|
||||
type: 'confirm',
|
||||
message: 'Delete everything?',
|
||||
defaultPromptText: '',
|
||||
origin: 'https://shop.example'
|
||||
}
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('names an embedded page that asks, not the site around it', () => {
|
||||
const { openDialog, shownPrompts } = setup()
|
||||
|
||||
openDialog('prompt', 'Re-enter your password', '', { origin: 'https://ads.example' })
|
||||
|
||||
expect(shownPrompts()[0]?.details).toMatchObject({ origin: 'https://ads.example' })
|
||||
})
|
||||
|
||||
it('takes an agent’s prompt answer and closes the page prompt', () => {
|
||||
const { guest, openDialog, closedPrompts } = setup()
|
||||
const callback = openDialog('prompt', 'Your name?', 'def')
|
||||
@@ -103,19 +69,6 @@ describe('browser JavaScript dialogs', () => {
|
||||
expect(answerBrowserJavaScriptDialog(guest.id, { accept: true, promptText: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('answers the page with what the user chose in the page prompt', async () => {
|
||||
const { renderer, guest, openDialog, shownPrompts } = setup()
|
||||
const callback = openDialog('confirm', 'Leave?')
|
||||
|
||||
await respondToBrowserPagePrompt(renderer, {
|
||||
requestId: shownPrompts()[0].requestId,
|
||||
answer: { accept: false, promptText: '' }
|
||||
})
|
||||
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(false, '')
|
||||
expect(getBrowserJavaScriptDialog(guest.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('turns away a second frame’s dialog and keeps the first one answerable', () => {
|
||||
const { guest, openDialog } = setup()
|
||||
const first = openDialog('confirm', 'Delete everything?')
|
||||
|
||||
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserPagePromptRequest } from '../../../../shared/browser/prompts/browser-page-prompt'
|
||||
import {
|
||||
BROWSER_PAGE_PROMPT_TIMEOUT_MS,
|
||||
cancelBrowserPagePrompts,
|
||||
openBrowserPagePrompt,
|
||||
respondToBrowserPagePrompt,
|
||||
showBrowserPagePromptsAwaitingRegistration,
|
||||
@@ -57,13 +56,6 @@ function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest[
|
||||
.map(([, request]) => request)
|
||||
}
|
||||
|
||||
function closedRequestIds(renderer: Electron.WebContents): string[] {
|
||||
return vi
|
||||
.mocked(renderer.send)
|
||||
.mock.calls.filter(([channel]) => channel === 'browser:page-prompt-closed')
|
||||
.map(([, event]) => event.requestId)
|
||||
}
|
||||
|
||||
const latestId = (renderer: Electron.WebContents): string =>
|
||||
sentRequests(renderer).at(-1)!.requestId
|
||||
|
||||
@@ -172,25 +164,4 @@ describe('browser page prompts', () => {
|
||||
await respondToBrowserPagePrompt(renderer, { requestId: latestId(renderer), answer: true })
|
||||
expect(ask(guest).opening).toBe('prompting')
|
||||
})
|
||||
|
||||
it('closes the prompt with a null answer when it expires or its page goes away', async () => {
|
||||
const renderer = mockRenderer(10)
|
||||
const expiring = ask(mockGuest(renderer, 'page-1'))
|
||||
const expiringId = latestId(renderer)
|
||||
const closingGuest = mockGuest(renderer, 'page-2')
|
||||
const closing = ask(closingGuest)
|
||||
const closingId = latestId(renderer)
|
||||
|
||||
cancelBrowserPagePrompts('page-2')
|
||||
vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(closedRequestIds(renderer)).toEqual([closingId, expiringId])
|
||||
expect(expiring.settle).toHaveBeenCalledWith(null)
|
||||
expect(closing.settle).toHaveBeenCalledWith(null)
|
||||
await expect(
|
||||
respondToBrowserPagePrompt(renderer, { requestId: closingId, answer: true })
|
||||
).resolves.toBe('rejected')
|
||||
expect(ask(closingGuest).opening).toBe('prompting')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getOrcaProfileBrowserSessionPartition } from '../../../../shared/orca-profiles'
|
||||
import { inspectRetiredBrowserSessionProfileUserAgentModes } from './browser-retired-profile-identity-inspection'
|
||||
|
||||
const ORCA_PROFILE_ID = 'local-default'
|
||||
const PROFILE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function profileWithMode(mode: unknown): Record<string, unknown> {
|
||||
return {
|
||||
id: PROFILE_ID,
|
||||
scope: 'isolated',
|
||||
partition: getOrcaProfileBrowserSessionPartition(ORCA_PROFILE_ID, PROFILE_ID),
|
||||
label: 'Existing',
|
||||
source: null,
|
||||
userAgentMode: mode
|
||||
}
|
||||
}
|
||||
|
||||
function unhydratableProfile(extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 'not-a-uuid',
|
||||
scope: 'isolated',
|
||||
partition: 'persist:orca-browser-session-not-a-uuid',
|
||||
label: 'Unhydratable',
|
||||
source: null,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
describe('retired browser profile identity inspection', () => {
|
||||
it('detects an inspectable old choice without removing its bytes', () => {
|
||||
const profile = profileWithMode('native')
|
||||
|
||||
expect(inspectRetiredBrowserSessionProfileUserAgentModes([profile], ORCA_PROFILE_ID)).toEqual({
|
||||
noticePending: true,
|
||||
degraded: false
|
||||
})
|
||||
expect(profile.userAgentMode).toBe('native')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ scenario: 'an unreadable mode', entry: profileWithMode('unexpected') },
|
||||
{
|
||||
scenario: 'a mode on an entry that cannot be hydrated',
|
||||
entry: unhydratableProfile({ userAgentMode: 'native' })
|
||||
}
|
||||
])('turns $scenario into a degraded notice without throwing', ({ entry }) => {
|
||||
expect(() =>
|
||||
inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)
|
||||
).not.toThrow()
|
||||
expect(inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)).toEqual({
|
||||
noticePending: true,
|
||||
degraded: true
|
||||
})
|
||||
})
|
||||
})
|
||||
-416
@@ -1,416 +0,0 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import {
|
||||
BrowserSessionUaCdpCollector,
|
||||
type BrowserSessionUaCdpRequest,
|
||||
waitForBrowserCdpEndpoint
|
||||
} from './browser-session-ua-cdp-collector'
|
||||
import {
|
||||
startBrowserSessionUaWireProbeServer,
|
||||
type WireProbeJavaScriptIdentity,
|
||||
type WireProbeReceipt
|
||||
} from './browser-session-ua-wire-probe-server'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
let processIdentityModulePath: string
|
||||
let exceptionModulePath: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-wire-identity-modules-'))
|
||||
fixtureRoots.push(root)
|
||||
processIdentityModulePath = join(root, 'browser-process-user-agent.cjs')
|
||||
exceptionModulePath = join(root, 'browser-session-ua.cjs')
|
||||
await Promise.all([
|
||||
buildModule(
|
||||
'src/main/browser/identity/browser-process-user-agent.ts',
|
||||
processIdentityModulePath
|
||||
),
|
||||
buildModule('src/main/browser/identity/requests/browser-session-ua.ts', exceptionModulePath)
|
||||
])
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type ProbeArm = 'clean' | 'fallback-disabled'
|
||||
|
||||
type ProbeResult = Readonly<{
|
||||
arm: ProbeArm
|
||||
rawUserAgent: string
|
||||
cleanUserAgent: string
|
||||
navigatorUserAgent: string
|
||||
receipts: readonly WireProbeReceipt[]
|
||||
identities: readonly WireProbeJavaScriptIdentity[]
|
||||
cdpRequests: readonly BrowserSessionUaCdpRequest[]
|
||||
cdpDiagnostics: readonly string[]
|
||||
fixtureResult: string
|
||||
fixtureStderr: string
|
||||
}>
|
||||
|
||||
describe('browser session wire identity in cross-site frames and dedicated workers', () => {
|
||||
it('keeps OOPIF, dedicated-worker, and client-hint identities clean', async () => {
|
||||
const result = await runProbe('clean')
|
||||
assertCapturedContexts(result)
|
||||
const checks = identityChecks(result)
|
||||
expect(checks.crossSiteDocument).toBe(true)
|
||||
expect(checks.crossSiteFetch).toBe(true)
|
||||
expect(checks.dedicatedWorkerScript).toBe(true)
|
||||
expect(checks.dedicatedWorkerFetch).toBe(true)
|
||||
expect(checks.clientHints, JSON.stringify(receiptForPath(result.receipts, '/'))).toBe(true)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
async function runProbe(arm: ProbeArm): Promise<ProbeResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), `orca-wire-cross-context-${arm}-`))
|
||||
fixtureRoots.push(root)
|
||||
const server = await startBrowserSessionUaWireProbeServer()
|
||||
const resultPath = join(root, 'result.json')
|
||||
const barrierPath = join(root, 'continue')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
const cdpPort = await reservePort()
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
fixtureMain({
|
||||
arm,
|
||||
barrierPath,
|
||||
exceptionModulePath,
|
||||
httpOrigin: server.httpOrigin,
|
||||
processIdentityModulePath,
|
||||
resultPath
|
||||
})
|
||||
)
|
||||
let process: ChildProcess | null = null
|
||||
let collector: BrowserSessionUaCdpCollector | null = null
|
||||
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | null = null
|
||||
try {
|
||||
process = launchFixture(fixturePath, root, cdpPort)
|
||||
await waitForBrowserCdpEndpoint(cdpPort)
|
||||
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
|
||||
collector = await BrowserSessionUaCdpCollector.connect(cdpPort)
|
||||
await collector.installAutoAttach()
|
||||
writeFileSync(barrierPath, '')
|
||||
const processResult = await waitForProcess(process)
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(
|
||||
processResult.code,
|
||||
`${fixtureResult}\n${processResult.stderr}\n${JSON.stringify({ diagnostics: collector.diagnostics, receipts: server.receipts, identities: server.identities })}`
|
||||
).toBe(0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes this exact shape before exiting.
|
||||
const parsed = JSON.parse(fixtureResult) as Omit<
|
||||
ProbeResult,
|
||||
| 'receipts'
|
||||
| 'identities'
|
||||
| 'cdpRequests'
|
||||
| 'cdpDiagnostics'
|
||||
| 'fixtureResult'
|
||||
| 'fixtureStderr'
|
||||
>
|
||||
return {
|
||||
...parsed,
|
||||
fixtureResult,
|
||||
fixtureStderr: processResult.stderr,
|
||||
receipts: [...server.receipts],
|
||||
identities: [...server.identities],
|
||||
cdpDiagnostics: [...collector.diagnostics],
|
||||
cdpRequests: collector.snapshot().filter(({ url }) => {
|
||||
return (
|
||||
url.startsWith(server.httpOrigin) ||
|
||||
url.startsWith(server.crossSiteOrigin) ||
|
||||
url.startsWith(server.httpsOrigin)
|
||||
)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
await collector?.close().catch(() => {})
|
||||
await browser?.close().catch(() => {})
|
||||
await server.close()
|
||||
if (process && process.exitCode === null) {
|
||||
process.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildModule(entry: string, outputPath: string): Promise<void> {
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: join(process.cwd(), entry),
|
||||
formats: ['cjs'],
|
||||
fileName: () => basename(outputPath)
|
||||
},
|
||||
outDir: join(outputPath, '..'),
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess {
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const args = [
|
||||
fixturePath,
|
||||
`--user-data-dir=${join(root, 'profile')}`,
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
'--site-per-process'
|
||||
]
|
||||
if (process.platform === 'linux') {
|
||||
args.push('--no-sandbox')
|
||||
}
|
||||
return spawn(
|
||||
process.platform === 'linux' ? 'xvfb-run' : electronBinary,
|
||||
process.platform === 'linux' ? ['--auto-servernum', electronBinary, ...args] : args,
|
||||
{
|
||||
env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function fixtureMain(options: {
|
||||
arm: ProbeArm
|
||||
barrierPath: string
|
||||
exceptionModulePath: string
|
||||
httpOrigin: string
|
||||
processIdentityModulePath: string
|
||||
resultPath: string
|
||||
}): string {
|
||||
return String.raw`
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { existsSync, writeFileSync } = require('node:fs')
|
||||
const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)})
|
||||
const { cleanElectronUserAgent } = require(${JSON.stringify(options.processIdentityModulePath)})
|
||||
const arm = ${JSON.stringify(options.arm)}
|
||||
app.setName('OrcaCrossContextFixture')
|
||||
app.commandLine.appendSwitch('site-per-process')
|
||||
const rawUserAgent = app.userAgentFallback
|
||||
if (arm === 'clean') processIdentity.initializeBrowserProcessUserAgent('clean')
|
||||
const waitForBarrier = async () => {
|
||||
const deadline = Date.now() + 15000
|
||||
while (!existsSync(${JSON.stringify(options.barrierPath)})) {
|
||||
if (Date.now() >= deadline) throw new Error('startup barrier timeout')
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: 'timeout' })); app.exit(2) }, 20000)
|
||||
await app.whenReady()
|
||||
await waitForBarrier()
|
||||
const sess = session.fromPartition('persist:wire-cross-context')
|
||||
sess.setCertificateVerifyProc((_request, callback) => callback(0))
|
||||
const windows = []
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-cross-context', sandbox: true } })
|
||||
windows.push(window)
|
||||
window.webContents.setWindowOpenHandler(() => ({
|
||||
action: 'allow',
|
||||
createWindow: options => {
|
||||
const popup = new BrowserWindow({ ...options, show: false })
|
||||
windows.push(popup)
|
||||
return popup.webContents
|
||||
}
|
||||
}))
|
||||
await window.loadURL(${JSON.stringify(options.httpOrigin)} + '/?cross-context=1')
|
||||
const [navigatorUserAgent] = await Promise.all([
|
||||
window.webContents.executeJavaScript('navigator.userAgent'),
|
||||
window.webContents.executeJavaScript('window.probePromise')
|
||||
])
|
||||
// Allow isolated-frame CDP events to flush before the fixture exits.
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent: cleanElectronUserAgent(rawUserAgent), navigatorUserAgent }))
|
||||
for (const candidate of windows) if (!candidate.isDestroyed()) candidate.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error) })); app.exit(1) })
|
||||
`
|
||||
}
|
||||
|
||||
function assertCapturedContexts(result: ProbeResult): void {
|
||||
const paths = new Set(result.receipts.map(({ path }) => path))
|
||||
for (const path of [
|
||||
'/',
|
||||
'/cross-site-frame',
|
||||
'/cross-site-frame-fetch',
|
||||
'/dedicated-worker.js',
|
||||
'/dedicated-worker-fetch',
|
||||
'/report/cross-site-frame',
|
||||
'/report/dedicated-worker'
|
||||
]) {
|
||||
expect(
|
||||
paths,
|
||||
`${result.arm} omitted ${path}\n cdp: ${JSON.stringify(result.cdpDiagnostics)}\n receipts: ${JSON.stringify(result.receipts.map((r) => r.path))}\n fixture: ${result.fixtureResult}\n stderr: ${result.fixtureStderr}`
|
||||
).toContain(path)
|
||||
}
|
||||
expect(
|
||||
result.cdpDiagnostics.some((message) => message.startsWith('attached:iframe:')),
|
||||
JSON.stringify(result.cdpDiagnostics)
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.cdpDiagnostics.some((message) => message.startsWith('attached:worker:')),
|
||||
JSON.stringify(result.cdpDiagnostics)
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.receipts
|
||||
.filter(({ path }) => path === '/cross-site-frame')
|
||||
.map(({ protocol }) => protocol)
|
||||
).toEqual(['https'])
|
||||
expect(
|
||||
result.receipts
|
||||
.filter(({ path }) => path === '/cross-site-frame-fetch')
|
||||
.map(({ protocol }) => protocol)
|
||||
).toEqual(['https'])
|
||||
expect(
|
||||
result.cdpRequests.some(({ url, targetType }) => {
|
||||
return new URL(url).pathname === '/cross-site-frame-fetch' && targetType === 'iframe'
|
||||
}),
|
||||
JSON.stringify(result.cdpRequests.filter(({ url }) => url.includes('cross-site-frame-fetch')))
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
function identityChecks(result: ProbeResult): Readonly<Record<string, boolean>> {
|
||||
const frameIdentity = identityForContext(result.identities, 'cross-site-frame')
|
||||
const workerIdentity = identityForContext(result.identities, 'dedicated-worker')
|
||||
const frameReceipt = receiptForPath(result.receipts, '/cross-site-frame-fetch')
|
||||
const workerScriptReceipt = receiptForPath(result.receipts, '/dedicated-worker.js')
|
||||
const workerFetchReceipt = receiptForPath(result.receipts, '/dedicated-worker-fetch')
|
||||
// Compare hints on a subsequent fetch because initial navigation may omit them.
|
||||
const rootReceipt = receiptForPath(result.receipts, '/report/document')
|
||||
return {
|
||||
crossSiteDocument: frameIdentity.userAgent === result.cleanUserAgent,
|
||||
crossSiteFetch: frameReceipt.userAgent === result.cleanUserAgent,
|
||||
dedicatedWorkerScript: workerScriptReceipt.userAgent === result.cleanUserAgent,
|
||||
dedicatedWorkerFetch:
|
||||
workerIdentity.userAgent === result.cleanUserAgent &&
|
||||
workerFetchReceipt.userAgent === result.cleanUserAgent,
|
||||
clientHints: clientHintIdentityIsClean(
|
||||
rootReceipt,
|
||||
identityForContext(result.identities, 'document'),
|
||||
result.cleanUserAgent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function clientHintIdentityIsClean(
|
||||
receipt: WireProbeReceipt,
|
||||
identity: WireProbeJavaScriptIdentity,
|
||||
cleanUserAgent: string
|
||||
): boolean {
|
||||
// Native UA-CH can remain Chromium-shaped; require a clean legacy UA on the same request.
|
||||
const secChUa = receipt.clientHints['sec-ch-ua']
|
||||
const wireBrands = parseSecChUa(secChUa)
|
||||
const navigatorBrands = readNavigatorBrands(identity.userAgentData)
|
||||
if (!secChUa || wireBrands.length === 0 || navigatorBrands.length === 0) {
|
||||
return false
|
||||
}
|
||||
const token = /electron|orca/i
|
||||
return (
|
||||
receipt.userAgent === cleanUserAgent &&
|
||||
!token.test(receipt.userAgent ?? '') &&
|
||||
!token.test(secChUa) &&
|
||||
!navigatorBrands.some(({ brand, version }) => token.test(brand) || token.test(version)) &&
|
||||
sameBrands(wireBrands, navigatorBrands)
|
||||
)
|
||||
}
|
||||
|
||||
function parseSecChUa(value: string | undefined): { brand: string; version: string }[] {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
const brands: { brand: string; version: string }[] = []
|
||||
const pattern = /"([^"]+)"\s*;\s*v="([^"]*)"/g
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
const brand = match[1]
|
||||
const version = match[2]
|
||||
if (brand !== undefined && version !== undefined) {
|
||||
brands.push({ brand, version })
|
||||
}
|
||||
}
|
||||
return brands
|
||||
}
|
||||
|
||||
function readNavigatorBrands(value: unknown): { brand: string; version: string }[] {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return []
|
||||
}
|
||||
const brandsValue = Object.entries(value).find(([key]) => key === 'brands')?.[1]
|
||||
if (!Array.isArray(brandsValue)) {
|
||||
return []
|
||||
}
|
||||
const brands: { brand: string; version: string }[] = []
|
||||
for (const entry of brandsValue) {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
continue
|
||||
}
|
||||
const fields = Object.fromEntries(Object.entries(entry))
|
||||
const brand = fields.brand
|
||||
const version = fields.version
|
||||
if (typeof brand === 'string' && typeof version === 'string') {
|
||||
brands.push({ brand, version })
|
||||
}
|
||||
}
|
||||
return brands
|
||||
}
|
||||
|
||||
function sameBrands(
|
||||
left: readonly { brand: string; version: string }[],
|
||||
right: readonly { brand: string; version: string }[]
|
||||
): boolean {
|
||||
const normalize = (brands: readonly { brand: string; version: string }[]) =>
|
||||
brands.map(({ brand, version }) => `${brand}\u0000${version}`).sort()
|
||||
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right))
|
||||
}
|
||||
|
||||
function identityForContext(
|
||||
identities: readonly WireProbeJavaScriptIdentity[],
|
||||
context: string
|
||||
): WireProbeJavaScriptIdentity {
|
||||
const matches = identities.filter((identity) => identity.context === context)
|
||||
expect(matches, context).toHaveLength(1)
|
||||
return matches[0]!
|
||||
}
|
||||
|
||||
function receiptForPath(receipts: readonly WireProbeReceipt[], path: string): WireProbeReceipt {
|
||||
const matches = receipts.filter((receipt) => receipt.path === path)
|
||||
expect(matches, path).not.toHaveLength(0)
|
||||
return matches[0]!
|
||||
}
|
||||
|
||||
async function reservePort(): Promise<number> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('cdp port unavailable')
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
return address.port
|
||||
}
|
||||
|
||||
function waitForProcess(process: ChildProcess): Promise<{ code: number | null; stderr: string }> {
|
||||
let stderr = ''
|
||||
process.stderr?.setEncoding('utf8')
|
||||
process.stderr?.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
process.once('error', reject)
|
||||
process.once('exit', (code) => resolve({ code, stderr }))
|
||||
})
|
||||
}
|
||||
-214
@@ -1,214 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { fromPartitionMock } = vi.hoisted(() => {
|
||||
const sessionsByPartition = new Map<string, Record<string, ReturnType<typeof vi.fn>>>()
|
||||
const fromPartitionMock = vi.fn((partition: string) => {
|
||||
const existing = sessionsByPartition.get(partition)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = {
|
||||
resolveProxy: vi.fn(async () => 'DIRECT'),
|
||||
setProxy: vi.fn(async () => {}),
|
||||
closeAllConnections: vi.fn(async () => {}),
|
||||
getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/43.0.0 Orca/1.0'),
|
||||
setUserAgent: vi.fn(),
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
on: vi.fn()
|
||||
}
|
||||
sessionsByPartition.set(partition, created)
|
||||
return created
|
||||
})
|
||||
return { sessionsByPartition, fromPartitionMock }
|
||||
})
|
||||
|
||||
const identityState = vi.hoisted(() => ({ unavailable: false }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
session: {
|
||||
defaultSession: { resolveProxy: vi.fn(async () => 'DIRECT'), setProxy: vi.fn(async () => {}) },
|
||||
fromPartition: fromPartitionMock
|
||||
}
|
||||
}))
|
||||
vi.mock('../../guest-lifecycle/manager/browser-manager', () => ({
|
||||
browserManager: {
|
||||
installCertificateRequestGuard: vi.fn(),
|
||||
removeCertificateRequestGuard: vi.fn(),
|
||||
notifyPermissionDenied: vi.fn(),
|
||||
handleGuestWillDownload: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../../site-settings/permissions/browser-media-access', () => ({
|
||||
hasSystemMediaAccess: vi.fn(() => false),
|
||||
requestSystemMediaAccess: vi.fn(async () => false)
|
||||
}))
|
||||
vi.mock('../../identity/requests/browser-session-ua', () => ({
|
||||
installBrowserSessionUserAgentPolicy: vi.fn(() => vi.fn())
|
||||
}))
|
||||
vi.mock('../../identity/browser-process-user-agent', () => ({
|
||||
getBrowserProcessUserAgentIdentity: () => {
|
||||
if (identityState.unavailable) {
|
||||
throw new Error('Browser process user agent is not initialized')
|
||||
}
|
||||
return {
|
||||
mode: 'clean',
|
||||
userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36'
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../webauthn/browser-webauthn-access', () => ({
|
||||
allowsBrowserWebAuthnPermission: vi.fn(() => false),
|
||||
clearBrowserWebAuthnAccessHandlers: vi.fn(),
|
||||
installBrowserWebAuthnAccessHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
clearBrowserSessionPartitionPolicies,
|
||||
installBrowserSessionPartitionPolicies
|
||||
} from './browser-session-partition-policies'
|
||||
|
||||
import {
|
||||
applyBrowserSessionProxies,
|
||||
setBrowserNetworkProxySettingsResolver
|
||||
} from '../proxy/browser-session-proxy'
|
||||
import { handleElectronProxyLogin } from '../../../network/electron-proxy-credentials'
|
||||
|
||||
let partitionCounter = 0
|
||||
function nextProfile() {
|
||||
partitionCounter += 1
|
||||
const partition = `persist:orca-browser-session-install-${partitionCounter}`
|
||||
return {
|
||||
id: `p${partitionCounter}`,
|
||||
scope: 'isolated' as const,
|
||||
partition,
|
||||
label: 'p',
|
||||
source: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('installBrowserSessionPartitionPolicies proxy wiring', () => {
|
||||
beforeEach(() => {
|
||||
setBrowserNetworkProxySettingsResolver(null)
|
||||
// Ignore host-shell proxy variables when testing settings.
|
||||
for (const key of [
|
||||
'HTTP_PROXY',
|
||||
'http_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'https_proxy',
|
||||
'ALL_PROXY',
|
||||
'all_proxy'
|
||||
]) {
|
||||
vi.stubEnv(key, '')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setBrowserNetworkProxySettingsResolver(null)
|
||||
vi.unstubAllEnvs()
|
||||
identityState.unavailable = false
|
||||
})
|
||||
|
||||
it('reports an unavailable process identity through the promise, not a synchronous throw', async () => {
|
||||
const profile = nextProfile()
|
||||
identityState.unavailable = true
|
||||
|
||||
let installation: Promise<void> | undefined
|
||||
expect(() => {
|
||||
installation = installBrowserSessionPartitionPolicies(profile)
|
||||
}).not.toThrow()
|
||||
await expect(installation).rejects.toThrow('Browser process user agent is not initialized')
|
||||
})
|
||||
|
||||
it('does not report a new partition ready until Electron finishes applying its proxy', async () => {
|
||||
let finishWrite: (() => void) | undefined
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'socks5://127.0.0.1:1080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
const profile = nextProfile()
|
||||
const sess = fromPartitionMock(profile.partition)
|
||||
sess.setProxy.mockImplementation(() => new Promise<void>((resolve) => (finishWrite = resolve)))
|
||||
|
||||
let ready = false
|
||||
const installation = installBrowserSessionPartitionPolicies(profile).then(() => (ready = true))
|
||||
await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledTimes(1))
|
||||
expect(ready).toBe(false)
|
||||
|
||||
finishWrite?.()
|
||||
await installation
|
||||
expect(ready).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers partition readiness after one transient proxy rejection', async () => {
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'http://proxy.example:8080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
const profile = nextProfile()
|
||||
const sess = fromPartitionMock(profile.partition)
|
||||
sess.setProxy.mockRejectedValueOnce(new Error('transient proxy failure'))
|
||||
|
||||
await expect(installBrowserSessionPartitionPolicies(profile)).resolves.toBeUndefined()
|
||||
|
||||
expect(sess.setProxy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('forgets failed startup credentials and restores them after recovery', async () => {
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'http://alice:secret@proxy.example:8080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
const profile = nextProfile()
|
||||
const sess = fromPartitionMock(profile.partition)
|
||||
sess.closeAllConnections.mockRejectedValue(new Error('proxy settlement failed'))
|
||||
|
||||
await expect(installBrowserSessionPartitionPolicies(profile)).rejects.toThrow(
|
||||
'proxy settlement failed'
|
||||
)
|
||||
const failedCallback = vi.fn()
|
||||
handleElectronProxyLogin(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ session: sess } as never,
|
||||
{} as never,
|
||||
{ isProxy: true, host: 'proxy.example', port: 8080 },
|
||||
failedCallback
|
||||
)
|
||||
expect(failedCallback).not.toHaveBeenCalled()
|
||||
|
||||
sess.closeAllConnections.mockResolvedValue(undefined)
|
||||
await expect(installBrowserSessionPartitionPolicies(profile)).resolves.toBeUndefined()
|
||||
const recoveredCallback = vi.fn()
|
||||
handleElectronProxyLogin(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ session: sess } as never,
|
||||
{} as never,
|
||||
{ isProxy: true, host: 'proxy.example', port: 8080 },
|
||||
recoveredCallback
|
||||
)
|
||||
expect(recoveredCallback).toHaveBeenCalledWith('alice', 'secret')
|
||||
})
|
||||
|
||||
it('cancels an in-flight policy retry when partition policies are cleared', async () => {
|
||||
let proxyUrl = 'http://old.example:8080'
|
||||
let finishWrite: (() => void) | undefined
|
||||
setBrowserNetworkProxySettingsResolver(() => ({ httpProxyUrl: proxyUrl }))
|
||||
const profile = nextProfile()
|
||||
const sess = fromPartitionMock(profile.partition)
|
||||
sess.setProxy.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (finishWrite = resolve))
|
||||
)
|
||||
|
||||
const installation = installBrowserSessionPartitionPolicies(profile)
|
||||
await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledTimes(1))
|
||||
clearBrowserSessionPartitionPolicies(profile.partition, sess as never)
|
||||
proxyUrl = 'http://new.example:8080'
|
||||
await applyBrowserSessionProxies([], { httpProxyUrl: proxyUrl })
|
||||
finishWrite?.()
|
||||
await installation
|
||||
|
||||
expect(sess.setProxy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
|
||||
import {
|
||||
browserPageZoomLevelToPercent,
|
||||
normalizeBrowserPageZoomLevel
|
||||
} from '../../../../shared/browser-page-zoom'
|
||||
import { browserPageZoomLevelToPercent } from '../../../../shared/browser-page-zoom'
|
||||
import { BrowserSiteDisplayController } from './browser-site-display-controller'
|
||||
import {
|
||||
configureBrowserSiteDisplayStore,
|
||||
@@ -55,12 +52,6 @@ function fakeGuest(url: string): FakeGuest {
|
||||
return guest
|
||||
}
|
||||
|
||||
function commit(controller: BrowserSiteDisplayController, guest: FakeGuest, url: string): void {
|
||||
guest.url = url
|
||||
controller.onMainFrameNavigationCommitted(guest.id)
|
||||
guest.emit('dom-ready')
|
||||
}
|
||||
|
||||
function uiStore(initial: Partial<PersistedUIState> = {}): {
|
||||
getUI: () => PersistedUIState
|
||||
onUIChanged: (listener: (ui: PersistedUIState) => void) => () => void
|
||||
@@ -89,29 +80,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('BrowserSiteDisplayController zoom', () => {
|
||||
it('keeps zoom per site and browser profile, and resets it to Default Zoom', () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const ui = uiStore({ browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(0.5) })
|
||||
controller.followZoomSettings(ui)
|
||||
const github = fakeGuest('https://github.com/a')
|
||||
const githubOtherPort = fakeGuest('https://github.com:8443/b')
|
||||
const githubWork = fakeGuest('https://github.com/c')
|
||||
const docs = fakeGuest('https://docs.example.com/')
|
||||
controller.onGuestRegistered(github, null)
|
||||
controller.onGuestRegistered(githubOtherPort, 'default')
|
||||
controller.onGuestRegistered(githubWork, 'work')
|
||||
controller.onGuestRegistered(docs, null)
|
||||
|
||||
controller.zoomPage(github.id, 'in')
|
||||
|
||||
expect([github, githubOtherPort, githubWork, docs].map(percent)).toEqual([125, 125, 110, 110])
|
||||
commit(controller, docs, 'https://github.com/d')
|
||||
expect(percent(docs)).toBe(125)
|
||||
|
||||
expect(controller.zoomPage(github.id, 'reset')).toMatchObject({ siteHasZoom: false })
|
||||
expect([github, githubOtherPort, docs].map(percent)).toEqual([110, 110, 110])
|
||||
})
|
||||
|
||||
it("restores the site's zoom after Orca's UI zoom is copied onto the page", () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const ui = uiStore()
|
||||
@@ -141,41 +109,6 @@ describe('BrowserSiteDisplayController zoom', () => {
|
||||
})
|
||||
|
||||
describe('BrowserSiteDisplayController color scheme', () => {
|
||||
it('uses chat media for document guests without changing zoom, and upgrades browser registration', () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const preview = fakeGuest('orca-preview://grant/theme.html')
|
||||
preview.level = 1
|
||||
controller.onGuestDocumentStart(preview, {
|
||||
url: preview.url,
|
||||
profileId: null,
|
||||
colorScheme: 'dark'
|
||||
})
|
||||
controller.onMainFrameNavigationCommitted(preview.id)
|
||||
expect(preview.mediaSent).toEqual(['dark', 'dark'])
|
||||
expect(preview.level).toBe(1)
|
||||
expect(preview.listenerCount('dom-ready')).toBe(0)
|
||||
|
||||
const local = fakeGuest('file:///chat-scratch/theme.html')
|
||||
controller.onGuestDocumentStart(local, {
|
||||
url: local.url,
|
||||
profileId: 'work',
|
||||
colorScheme: 'dark'
|
||||
})
|
||||
controller.onGuestRegistered(local, 'work')
|
||||
controller.setColorScheme(local.id, 'light')
|
||||
controller.setGuestColorSchemeFallback(local, 'light')
|
||||
controller.setGuestColorSchemeFallback(local, 'dark')
|
||||
expect(local.mediaSent.at(-1)).toBe('light')
|
||||
expect(controller.getPageDisplay(local.id)?.colorScheme).toBe('light')
|
||||
controller.setColorScheme(local.id, null)
|
||||
expect(local.mediaSent.at(-1)).toBe('dark')
|
||||
controller.setGuestColorSchemeFallback(local, undefined)
|
||||
commit(controller, local, 'https://ordinary.example/')
|
||||
expect(local.mediaSent.slice(-2)).toEqual(['dark', ''])
|
||||
controller.onGuestRetired(preview.id)
|
||||
expect(preview.debugger.isAttached()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ order: 'read-first', profileId: 'work', colorScheme: 'light', saved: 'dark' },
|
||||
{ order: 'registration-first', profileId: 'work', colorScheme: 'light', saved: 'dark' }
|
||||
|
||||
@@ -20,19 +20,12 @@ import {
|
||||
BROWSER_PAGE_PROMPT_TIMEOUT_MS,
|
||||
respondToBrowserPagePrompt
|
||||
} from '../../guest-lifecycle/prompts/browser-page-prompts'
|
||||
import {
|
||||
decideBrowserSitePermissions,
|
||||
isBrowserSitePermissionAllowed
|
||||
} from './browser-site-permission-prompt'
|
||||
import { decideBrowserSitePermissions } from './browser-site-permission-prompt'
|
||||
import {
|
||||
savedBrowserSitePermissions,
|
||||
sessionBrowserSitePermissions,
|
||||
type BrowserSitePermissionMemory
|
||||
} from './browser-site-permission-memory'
|
||||
import {
|
||||
configureBrowserSitePermissionStore,
|
||||
listBrowserSitePermissions
|
||||
} from './browser-site-permission-store'
|
||||
import { configureBrowserSitePermissionStore } from './browser-site-permission-store'
|
||||
|
||||
type MockGuest = Electron.WebContents & EventEmitter
|
||||
|
||||
@@ -83,20 +76,6 @@ function ask(
|
||||
return decideBrowserSitePermissions({ requester: guest, frameOrigin, memory, permissions })
|
||||
}
|
||||
|
||||
function allowedOnCheck(
|
||||
permissions: BrowserSitePermission[],
|
||||
{
|
||||
memory = savedBrowserSitePermissions('default'),
|
||||
frameOrigin = 'https://meet.example.com'
|
||||
}: { memory?: BrowserSitePermissionMemory; frameOrigin?: string } = {}
|
||||
): boolean {
|
||||
return isBrowserSitePermissionAllowed({
|
||||
source: { pageUrl: 'https://meet.example.com/room', frameOrigin },
|
||||
memory,
|
||||
permissions
|
||||
})
|
||||
}
|
||||
|
||||
async function answerLatest(value: unknown): Promise<void> {
|
||||
const request = sentRequests(renderer).at(-1)
|
||||
if (!request) {
|
||||
@@ -140,57 +119,4 @@ describe('browser site permission prompts', () => {
|
||||
await expect(ask(['camera', 'microphone'])).resolves.toBe(false)
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("keeps a paired server's answers for its session only, where checks still see them", async () => {
|
||||
const pairedSession = sessionBrowserSitePermissions('persist:paired-a')
|
||||
const call = ask(['notifications'], { memory: pairedSession })
|
||||
await answerLatest('allow')
|
||||
await expect(call).resolves.toBe(true)
|
||||
|
||||
expect(allowedOnCheck(['notifications'], { memory: pairedSession })).toBe(true)
|
||||
await expect(ask(['notifications'], { memory: pairedSession })).resolves.toBe(true)
|
||||
expect(sentRequests(renderer)).toHaveLength(1)
|
||||
expect(listBrowserSitePermissions('default')).toEqual([])
|
||||
void ask(['notifications'], { memory: sessionBrowserSitePermissions('persist:paired-b') })
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("gives another site's frame only its own notification answer, and never asks for it", async () => {
|
||||
const tab = ask(['notifications'])
|
||||
await answerLatest('allow')
|
||||
await tab
|
||||
const widgetPage = ask(['notifications'], { guest: mockGuest('https://widget.example.net/') })
|
||||
await answerLatest('allow')
|
||||
await widgetPage
|
||||
|
||||
expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://widget.example.net' })).toBe(
|
||||
true
|
||||
)
|
||||
await expect(
|
||||
ask(['notifications'], { frameOrigin: 'https://widget.example.net' })
|
||||
).resolves.toBe(true)
|
||||
expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://ads.example.net' })).toBe(
|
||||
false
|
||||
)
|
||||
await expect(ask(['notifications'], { frameOrigin: 'https://ads.example.net' })).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('remembers answers across a restart', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))
|
||||
configureBrowserSitePermissionStore(directory)
|
||||
const call = ask(['midi'])
|
||||
await answerLatest('allow')
|
||||
await call
|
||||
|
||||
// Switch directories to evict the cache and verify the next read comes from disk.
|
||||
configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-')))
|
||||
expect(listBrowserSitePermissions('default')).toEqual([])
|
||||
configureBrowserSitePermissionStore(directory)
|
||||
expect(listBrowserSitePermissions('default')).toEqual([
|
||||
{ origin: 'https://meet.example.com', decisions: { midi: 'allow' } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
+14
-18
@@ -83,24 +83,20 @@ it('rolls back the entire Codex exit settlement when a later row fails', async (
|
||||
const diskBefore = liveTestJournalRows(database.db, SESSION)
|
||||
const sawEarlierInserts: number[] = []
|
||||
const transaction = database.transaction.bind(database)
|
||||
vi.spyOn(database, 'transaction').mockImplementation((run, streamed, committed) =>
|
||||
transaction(
|
||||
(db) => {
|
||||
try {
|
||||
return run(db)
|
||||
} catch (error) {
|
||||
sawEarlierInserts.push(liveTestJournalRows(db, SESSION).length - diskBefore.length)
|
||||
expect(journal.snapshot()).toEqual(before)
|
||||
expect(commits).not.toHaveBeenCalled()
|
||||
expect(
|
||||
published.every((snapshot) => JSON.stringify(snapshot) === JSON.stringify(before))
|
||||
).toBe(true)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
streamed,
|
||||
committed
|
||||
)
|
||||
vi.spyOn(database, 'transaction').mockImplementation((run, committed) =>
|
||||
transaction((db) => {
|
||||
try {
|
||||
return run(db)
|
||||
} catch (error) {
|
||||
sawEarlierInserts.push(liveTestJournalRows(db, SESSION).length - diskBefore.length)
|
||||
expect(journal.snapshot()).toEqual(before)
|
||||
expect(commits).not.toHaveBeenCalled()
|
||||
expect(
|
||||
published.every((snapshot) => JSON.stringify(snapshot) === JSON.stringify(before))
|
||||
).toBe(true)
|
||||
throw error
|
||||
}
|
||||
}, committed)
|
||||
)
|
||||
database.db.exec(`CREATE TEMP TRIGGER reject_codex_later BEFORE INSERT ON journal_rows
|
||||
WHEN NEW.session_id = '${SESSION}' AND NEW.seq > ${before.cursor.sequence + 1}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapBridgeError } from './desktop-script-provider-bridge'
|
||||
|
||||
describe('mapBridgeError', () => {
|
||||
it('keeps the code a provider refusal names', () => {
|
||||
expect(
|
||||
mapBridgeError(
|
||||
'element_not_enabled: element 4 is disabled, so no input was sent; run get-app-state and use a fresh element index'
|
||||
).code
|
||||
).toBe('element_not_enabled')
|
||||
})
|
||||
})
|
||||
@@ -158,6 +158,11 @@ function runBridgeProcess(
|
||||
|
||||
export function mapBridgeError(message: string): RuntimeClientError {
|
||||
const text = message.trim() || 'desktop provider failed'
|
||||
// Why: refusals that name their code must keep it even when the explanation matches a looser rule below.
|
||||
const coded = /^(background_unavailable|element_not_enabled):/.exec(text)?.[1]
|
||||
if (coded) {
|
||||
return new RuntimeClientError(coded, text)
|
||||
}
|
||||
if (/text_input_incomplete|window_not_responding/i.test(text)) {
|
||||
return new RuntimeClientError('action_timeout', text)
|
||||
}
|
||||
@@ -178,7 +183,7 @@ export function mapBridgeError(message: string): RuntimeClientError {
|
||||
return new RuntimeClientError('unsupported_capability', text)
|
||||
}
|
||||
if (
|
||||
/unsupported mouse button|unsupported scroll direction|unsupported (?:key|modifier)|windowId is not supported|must be a positive|must be a finite number|\b(?:x|y|from_x|from_y|to_x|to_y|pages|click_count|text|key|direction) is required\b/i.test(
|
||||
/unsupported mouse button|unsupported scroll direction|outside the target window|unsupported (?:key|modifier)|windowId is not supported|must be a positive|must be a finite number|\b(?:x|y|from_x|from_y|to_x|to_y|pages|click_count|text|key|direction) is required\b/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
const SAMPLING_INTERVAL_BYTES = 512 * 1024
|
||||
const START_HEAP_RATIO = 0.6
|
||||
const STOP_HEAP_RATIO = 0.5
|
||||
// Main refreshes independently because hidden renderers throttle memory samples.
|
||||
const PROFILE_REFRESH_MS = 30_000
|
||||
// Each profile read forces a full renderer GC, so after the first read require this much growth.
|
||||
const PROFILE_READ_GROWTH_RATIO = 0.05
|
||||
// Main checks the heap itself because hidden renderers throttle memory samples.
|
||||
const HEAP_CHECK_MS = 30_000
|
||||
|
||||
export type ProfiledDebugger = {
|
||||
isAttached(): boolean
|
||||
@@ -61,8 +63,8 @@ export function profiledRendererFor(contents: WebContents): ProfiledRenderer {
|
||||
|
||||
export type RendererHeapReading = {
|
||||
heapRatio: number
|
||||
heapLimitBytes: number
|
||||
rendererSurface: string
|
||||
isHighwaterMark: boolean
|
||||
}
|
||||
|
||||
type SamplingSession = {
|
||||
@@ -70,9 +72,13 @@ type SamplingSession = {
|
||||
startedAtPct: number
|
||||
startedAtMs: number
|
||||
trustedScriptPrefix: string | null
|
||||
heapLimitBytes: number
|
||||
admittedHeapRatio: number
|
||||
profileReadAdmitted: boolean
|
||||
// Wait for startSampling before requesting a profile.
|
||||
ready: boolean
|
||||
reading: boolean
|
||||
checking: boolean
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
@@ -94,23 +100,31 @@ export function noteRendererHeapReading(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (reading.heapRatio < STOP_HEAP_RATIO) {
|
||||
noteSessionHeapRatio(renderer, session, reading.heapRatio, false, record)
|
||||
}
|
||||
|
||||
function noteSessionHeapRatio(
|
||||
renderer: ProfiledRenderer,
|
||||
session: SamplingSession,
|
||||
heapRatio: number,
|
||||
fromHeapCheck: boolean,
|
||||
record: (data: CrashReportBreadcrumbData) => void
|
||||
): void {
|
||||
if (heapRatio < STOP_HEAP_RATIO) {
|
||||
session.stop()
|
||||
return
|
||||
}
|
||||
if (reading.isHighwaterMark) {
|
||||
refreshProfile(renderer, session, record)
|
||||
}
|
||||
}
|
||||
|
||||
function refreshProfile(
|
||||
renderer: ProfiledRenderer,
|
||||
session: SamplingSession,
|
||||
record: (data: CrashReportBreadcrumbData) => void
|
||||
): void {
|
||||
if (!session.ready || session.reading) {
|
||||
return
|
||||
}
|
||||
// The first heap check reads even without growth so a steady high heap still has evidence.
|
||||
const firstReadDue = fromHeapCheck && !session.profileReadAdmitted
|
||||
if (!firstReadDue && heapRatio < session.admittedHeapRatio + PROFILE_READ_GROWTH_RATIO) {
|
||||
return
|
||||
}
|
||||
// Admission, not success, consumes the allowance: a failed read may already have paid for its GC.
|
||||
session.profileReadAdmitted = true
|
||||
session.admittedHeapRatio = Math.max(session.admittedHeapRatio, heapRatio)
|
||||
session.reading = true
|
||||
void renderer.debugger
|
||||
.sendCommand('HeapProfiler.getSamplingProfile')
|
||||
@@ -137,7 +151,7 @@ function startSampling(
|
||||
record: (data: CrashReportBreadcrumbData) => void
|
||||
): void {
|
||||
const debug = renderer.debugger
|
||||
// Do not take over a debugger attached by DevTools or another diagnostic client.
|
||||
// Only sees Orca's own debugger client (the lag capture); other CDP sessions are not visible here.
|
||||
if (debug.isAttached()) {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +173,7 @@ function startSampling(
|
||||
debug.removeListener('detach', onDetach)
|
||||
unwatch()
|
||||
if (!renderer.isDestroyed() && debug.isAttached()) {
|
||||
void debug.sendCommand('HeapProfiler.stopSampling').catch(() => undefined)
|
||||
// Detaching ends the inspector session, which stops sampling without stopSampling's forced GC.
|
||||
try {
|
||||
debug.detach()
|
||||
} catch {
|
||||
@@ -175,8 +189,12 @@ function startSampling(
|
||||
startedAtPct: Math.round(reading.heapRatio * 100),
|
||||
startedAtMs: Date.now(),
|
||||
trustedScriptPrefix: trustedScriptPrefixFor(renderer.getURL()),
|
||||
heapLimitBytes: reading.heapLimitBytes,
|
||||
admittedHeapRatio: reading.heapRatio,
|
||||
profileReadAdmitted: false,
|
||||
ready: false,
|
||||
reading: false,
|
||||
checking: false,
|
||||
stop
|
||||
}
|
||||
sessions.set(renderer.id, session)
|
||||
@@ -192,15 +210,40 @@ function startSampling(
|
||||
return
|
||||
}
|
||||
session.ready = true
|
||||
refreshTimer = setInterval(
|
||||
() => refreshProfile(renderer, session, record),
|
||||
PROFILE_REFRESH_MS
|
||||
)
|
||||
refreshTimer = setInterval(() => checkHeap(renderer, session, record), HEAP_CHECK_MS)
|
||||
refreshTimer.unref?.()
|
||||
})
|
||||
.catch(stop)
|
||||
}
|
||||
|
||||
function checkHeap(
|
||||
renderer: ProfiledRenderer,
|
||||
session: SamplingSession,
|
||||
record: (data: CrashReportBreadcrumbData) => void
|
||||
): void {
|
||||
if (session.checking) {
|
||||
return
|
||||
}
|
||||
session.checking = true
|
||||
void renderer.debugger
|
||||
.sendCommand('Runtime.getHeapUsage')
|
||||
.then((result) => {
|
||||
const used =
|
||||
result && typeof result === 'object' && 'usedSize' in result ? result.usedSize : undefined
|
||||
if (
|
||||
typeof used === 'number' &&
|
||||
Number.isFinite(used) &&
|
||||
sessions.get(renderer.id) === session
|
||||
) {
|
||||
noteSessionHeapRatio(renderer, session, used / session.heapLimitBytes, true, record)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
session.checking = false
|
||||
})
|
||||
}
|
||||
|
||||
export function profileRendererMemoryBreadcrumb(
|
||||
renderer: ProfiledRenderer,
|
||||
args: { name?: unknown; data?: unknown } | undefined,
|
||||
@@ -217,21 +260,29 @@ export function profileRendererMemoryBreadcrumb(
|
||||
function readHeapReading(
|
||||
args: { name?: unknown; data?: unknown } | undefined
|
||||
): RendererHeapReading | null {
|
||||
const isHighwaterMark = args?.name === 'renderer_memory_highwater'
|
||||
const data = args?.data
|
||||
if ((!isHighwaterMark && args?.name !== 'renderer_memory') || !data || typeof data !== 'object') {
|
||||
if (
|
||||
(args?.name !== 'renderer_memory_highwater' && args?.name !== 'renderer_memory') ||
|
||||
!data ||
|
||||
typeof data !== 'object'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const used = 'usedHeapMB' in data ? data.usedHeapMB : undefined
|
||||
const limit = 'heapLimitMB' in data ? data.heapLimitMB : undefined
|
||||
if (typeof used !== 'number' || typeof limit !== 'number' || !(limit > 0)) {
|
||||
if (
|
||||
typeof used !== 'number' ||
|
||||
!Number.isFinite(used) ||
|
||||
typeof limit !== 'number' ||
|
||||
!(limit > 0)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const surface = 'rendererSurface' in data ? data.rendererSurface : undefined
|
||||
return {
|
||||
heapRatio: used / limit,
|
||||
rendererSurface: typeof surface === 'string' ? surface : 'unknown',
|
||||
isHighwaterMark
|
||||
heapLimitBytes: limit * 1024 * 1024,
|
||||
rendererSurface: typeof surface === 'string' ? surface : 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectCrashDiagnosticBundleAttachment(): Promise<CrashDiagnosticBundleAttachment> {
|
||||
async function collectCrashDiagnosticBundleAttachment(
|
||||
nativeHangCaptureId?: string
|
||||
): Promise<CrashDiagnosticBundleAttachment> {
|
||||
const status = getDiagnosticsStatus()
|
||||
if (!status.bundleEnabled) {
|
||||
return {
|
||||
@@ -47,7 +49,8 @@ async function collectCrashDiagnosticBundleAttachment(): Promise<CrashDiagnostic
|
||||
osRelease: os.release(),
|
||||
orcaChannel: resolveDiagnosticOrcaChannel(),
|
||||
// Reports after relaunch need logs older than the default 30-minute support window.
|
||||
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
|
||||
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES,
|
||||
nativeHangCaptureId
|
||||
})
|
||||
} catch (error) {
|
||||
return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
|
||||
@@ -70,10 +73,11 @@ async function collectCrashDiagnosticBundleAttachment(): Promise<CrashDiagnostic
|
||||
}
|
||||
|
||||
export async function prepareCrashDiagnosticBundle(
|
||||
includeDiagnosticLogs: boolean
|
||||
includeDiagnosticLogs: boolean,
|
||||
nativeHangCaptureId?: string
|
||||
): Promise<CrashDiagnosticBundleAttachment> {
|
||||
return includeDiagnosticLogs
|
||||
? collectCrashDiagnosticBundleAttachment()
|
||||
? collectCrashDiagnosticBundleAttachment(nativeHangCaptureId)
|
||||
: skippedCrashDiagnosticBundle()
|
||||
}
|
||||
|
||||
|
||||
@@ -92,16 +92,7 @@ export class CrashReportStore {
|
||||
|
||||
async record(input: CrashReportCreateInput): Promise<CrashReportRecord> {
|
||||
return this.withWrite(async (reports) => {
|
||||
const report: CrashReportRecord = {
|
||||
...input,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
details: sanitizeCrashReportDetails(input.details),
|
||||
breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs)?.map(
|
||||
({ origin: _origin, ...breadcrumb }) => breadcrumb
|
||||
)
|
||||
}
|
||||
const report = this.createRecord(input)
|
||||
return {
|
||||
reports: [report, ...reports].slice(0, MAX_REPORTS),
|
||||
result: report
|
||||
@@ -109,6 +100,41 @@ export class CrashReportStore {
|
||||
})
|
||||
}
|
||||
|
||||
async importHangReport(input: CrashReportCreateInput): Promise<CrashReportRecord | null> {
|
||||
return this.withWrite(async (reports) => {
|
||||
const existing = reports.find(
|
||||
(report) =>
|
||||
report.reason === 'main-thread-hang' &&
|
||||
report.details.captureId === input.details.captureId
|
||||
)
|
||||
if (existing) {
|
||||
return { reports, result: existing }
|
||||
}
|
||||
if (
|
||||
reports.some(
|
||||
(report) => report.status === 'pending' && report.reason !== 'main-thread-hang'
|
||||
)
|
||||
) {
|
||||
return { reports, result: null }
|
||||
}
|
||||
const report: CrashReportRecord = { ...this.createRecord(input), status: 'dismissed' }
|
||||
return { reports: [report, ...reports].slice(0, MAX_REPORTS), result: report }
|
||||
})
|
||||
}
|
||||
|
||||
private createRecord(input: CrashReportCreateInput): CrashReportRecord {
|
||||
return {
|
||||
...input,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
details: sanitizeCrashReportDetails(input.details),
|
||||
breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs)?.map(
|
||||
({ origin: _origin, ...breadcrumb }) => breadcrumb
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Crashpad may finish writing after process-gone; merge its signature into the existing report. */
|
||||
async attachDetails(
|
||||
id: string,
|
||||
@@ -203,7 +229,9 @@ export class CrashReportStore {
|
||||
// Reading directly avoids waiting on the write chain this callback already owns.
|
||||
const reports = await this.readReportsFromDisk()
|
||||
const { reports: nextReports, result } = await mutate(reports)
|
||||
await this.writeReports(nextReports)
|
||||
if (nextReports !== reports) {
|
||||
await this.writeReports(nextReports)
|
||||
}
|
||||
return result
|
||||
})
|
||||
this.writeChain = run.then(
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonAutomaticWorkGuard } from '../daemon-automatic-work-guard'
|
||||
import { DaemonProcessGrowthMonitor } from '../daemon-process-growth-monitor'
|
||||
|
||||
describe('DaemonAutomaticWorkGuard stop request', () => {
|
||||
it('records an outside stop on the marker before teardown, so a cut-short exit is no crash', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-stop-'))
|
||||
try {
|
||||
const markerPath = join(dir, 'daemon-v1.automatic-work-run.json')
|
||||
const options = {
|
||||
markerPath,
|
||||
mode: 'enforce' as const,
|
||||
log: { log: vi.fn() },
|
||||
listSessions: () => [],
|
||||
ownsEndpoint: () => true,
|
||||
monitor: new DaemonProcessGrowthMonitor({
|
||||
listSessions: () => [],
|
||||
recentAutomaticWork: () => false,
|
||||
sampleWhenIdle: false,
|
||||
onLevelChanged: () => {},
|
||||
onTrip: () => {},
|
||||
readRows: async () => []
|
||||
})
|
||||
}
|
||||
const guard = new DaemonAutomaticWorkGuard(options)
|
||||
guard.start()
|
||||
guard['marker'].arm()
|
||||
expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toMatchObject({ state: 'running' })
|
||||
|
||||
guard.recordStopRequested()
|
||||
|
||||
expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toMatchObject({ state: 'clean-exit' })
|
||||
const next = new DaemonAutomaticWorkGuard(options)
|
||||
next.start()
|
||||
expect(next.status().mode).toEqual({ kind: 'normal' })
|
||||
next.stop()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -29,10 +29,12 @@ import { startDaemonScopeDeathWatch } from './process-scopes/daemon-scope-death-
|
||||
import { isWindowsProcessTableAvailable } from '../windows/windows-process-table'
|
||||
import { leaseAppImageGenerationForCurrentProcess } from '../cli/appimage/appimage-generation-lease'
|
||||
import { createDaemonShutdownDeadline } from './launch/daemon-shutdown-deadline'
|
||||
import { setDaemonProcessName } from './daemon-process-name'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// The parent closes startup stderr after readiness; later EPIPE errors must not kill the daemon.
|
||||
process.stderr.on('error', () => {})
|
||||
setDaemonProcessName()
|
||||
|
||||
const {
|
||||
socketPath,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
export function setDaemonProcessName(): void {
|
||||
if (process.platform !== 'linux') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// process.title overwrites argv, which daemon adoption and recovery use as identity evidence.
|
||||
writeFileSync('/proc/self/comm', 'orca-agents')
|
||||
} catch {
|
||||
// Naming must not prevent terminals starting when procfs is unavailable or read-only.
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { rm } from '../../../filesystem/asar-transparent-fs'
|
||||
import { macDaemonCopyBundlePath } from './macos-daemon-bundle'
|
||||
import { MAC_TERMINAL_HOST_BUNDLE } from './macos-daemon-bundle'
|
||||
|
||||
const LSREGISTER =
|
||||
'/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'
|
||||
@@ -27,13 +27,20 @@ export async function retireUnusedMacDaemonCopy(directory: string): Promise<void
|
||||
if (!unused) {
|
||||
return
|
||||
}
|
||||
// Direct execution may register this copy with LaunchServices.
|
||||
await runProcess({
|
||||
program: LSREGISTER,
|
||||
args: ['-u', macDaemonCopyBundlePath(directory)],
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 8192
|
||||
}).catch(() => {})
|
||||
// Old copies retain their original bundle name when the helper is renamed.
|
||||
const parent = join(directory, 'app.noindex')
|
||||
const bundles = await readdir(parent).catch(() => [])
|
||||
for (const bundle of bundles) {
|
||||
if (bundle !== MAC_TERMINAL_HOST_BUNDLE && bundle !== 'Orca Terminal Host.app') {
|
||||
continue
|
||||
}
|
||||
await runProcess({
|
||||
program: LSREGISTER,
|
||||
args: ['-u', join(parent, bundle)],
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 8192
|
||||
}).catch(() => {})
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Retain code when its use cannot be verified.
|
||||
|
||||
@@ -7,9 +7,9 @@ import { rm } from '../../../filesystem/asar-transparent-fs'
|
||||
import { ensurePrivateDir } from '../../endpoint/publication/daemon-private-file-modes'
|
||||
import { inspectMacProcessCodeIdentity } from './daemon-mac-code-identity'
|
||||
|
||||
/** Plain Node helper packaged by config/scripts/macos-terminal-host-bundle.cjs. */
|
||||
export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Terminal Host.app'
|
||||
export const MAC_TERMINAL_HOST_EXECUTABLE = 'orca-terminal-host'
|
||||
/** Plain Node helper packaged by config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs. */
|
||||
export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Agents.app'
|
||||
export const MAC_TERMINAL_HOST_EXECUTABLE = 'Orca Agents'
|
||||
export const MAC_DAEMON_COPY_PREFIX = 'terminal-host-'
|
||||
|
||||
export type MacDaemonBundle = {
|
||||
|
||||
+23
-2
@@ -29,9 +29,9 @@ afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function copy(name: string): Promise<string> {
|
||||
async function copy(name: string, bundle = 'Orca Agents.app'): Promise<string> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(join(directory, 'app.noindex', 'Orca Terminal Host.app'), { recursive: true })
|
||||
await mkdir(join(directory, 'app.noindex', bundle), { recursive: true })
|
||||
return directory
|
||||
}
|
||||
|
||||
@@ -68,6 +68,27 @@ it.each([
|
||||
expect(await exists(directory)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['Orca Agents.app', 'Orca Terminal Host.app'])(
|
||||
'unregisters an unused %s before deleting its copy',
|
||||
async (bundle) => {
|
||||
const directory = await copy('terminal-host-old', bundle)
|
||||
run.mockImplementation(async (spec) => {
|
||||
if (spec.program === LSREGISTER) {
|
||||
expect(await exists(directory)).toBe(true)
|
||||
}
|
||||
return unused
|
||||
})
|
||||
await retireUnusedMacDaemonCopy(directory)
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
program: LSREGISTER,
|
||||
args: ['-u', join(directory, 'app.noindex', bundle)]
|
||||
})
|
||||
)
|
||||
expect(await exists(directory)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it("retires every unused copy and staging dir except the current build's and this process's staging", async () => {
|
||||
const current = await copy('terminal-host-current')
|
||||
const oldUnused = await copy('terminal-host-old')
|
||||
|
||||
@@ -35,13 +35,13 @@ let verifyCode: number
|
||||
let onCopy: (() => Promise<void>) | null
|
||||
|
||||
const signal = (): AbortSignal => new AbortController().signal
|
||||
const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'orca-terminal-host')
|
||||
const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'Orca Agents')
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-mac-runtime-')))
|
||||
// Model an update that moved the running bundle and replaced its original path.
|
||||
source = join(root, 'com.stablyai.orca.ShipIt.old', 'Orca.app')
|
||||
helper = join(source, 'Contents', 'Helpers', 'Orca Terminal Host.app')
|
||||
helper = join(source, 'Contents', 'Helpers', 'Orca Agents.app')
|
||||
copies = join(root, 'profile', 'daemon-host', 'macos')
|
||||
await mkdir(join(source, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(join(helper, 'Contents', 'MacOS'), { recursive: true })
|
||||
@@ -101,14 +101,7 @@ it('publishes one verified copy per helper build, from the running app rather th
|
||||
await rm(source, { recursive: true })
|
||||
expect(copy.directory).toBe(join(copies, `terminal-host-${cdhash}`))
|
||||
expect(copy.execPath).toBe(
|
||||
join(
|
||||
copy.directory,
|
||||
'app.noindex',
|
||||
'Orca Terminal Host.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'orca-terminal-host'
|
||||
)
|
||||
join(copy.directory, 'app.noindex', 'Orca Agents.app', 'Contents', 'MacOS', 'Orca Agents')
|
||||
)
|
||||
expect(await readFile(copy.execPath, 'utf8')).toBe('signed-node')
|
||||
expect(await readFile(copy.entryPath, 'utf8')).toBe('daemon-code')
|
||||
@@ -136,9 +129,9 @@ it("rejects a copy that does not satisfy the running app's requirement and publi
|
||||
it('keeps the winning copy when a concurrent publisher renames first', async () => {
|
||||
onCopy = async () => {
|
||||
onCopy = null
|
||||
const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Terminal Host.app')
|
||||
const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Agents.app')
|
||||
await cp(helper, winner, { recursive: true })
|
||||
await writeFile(join(winner, 'Contents', 'MacOS', 'orca-terminal-host'), 'winner')
|
||||
await writeFile(join(winner, 'Contents', 'MacOS', 'Orca Agents'), 'winner')
|
||||
}
|
||||
const copy = await materializeMacDaemonBundle(copies, signal())
|
||||
expect(await readFile(copy.execPath, 'utf8')).toBe('winner')
|
||||
@@ -155,7 +148,7 @@ it('stops at the shared deadline and removes the staging copy', async () => {
|
||||
return controller.signal.aborted ? { ...ok, code: null } : (signed?.(spec) ?? ok)
|
||||
})
|
||||
await expect(materializeMacDaemonBundle(copies, controller.signal)).rejects.toThrow(
|
||||
'Could not copy Orca Terminal Host.app'
|
||||
'Could not copy Orca Agents.app'
|
||||
)
|
||||
expect(await readdir(copies)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ const nativePlatform = process.platform
|
||||
const originalGetuid = Object.getOwnPropertyDescriptor(process, 'getuid')
|
||||
const originalGetSystemVersion = Object.getOwnPropertyDescriptor(process, 'getSystemVersion')
|
||||
const roomyDeadline = (): number => Date.now() + 60_000
|
||||
const helperExec = (): string => join(copyDir, 'MacOS', 'orca-terminal-host')
|
||||
const helperExec = (): string => join(copyDir, 'MacOS', 'Orca Agents')
|
||||
const helperEntry = (): string => join(copyDir, 'daemon-entry.js')
|
||||
const service = (): string => `gui/501/${macDaemonJobLabel(state.root)}`
|
||||
const launchctlCalls = (verb: string): ProcessSpec[] =>
|
||||
|
||||
@@ -82,22 +82,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not kill a preserved daemon when startup lease acquisition fails', async () => {
|
||||
const mod = await importFresh()
|
||||
ensureRunningOverrides.push(async () => ({
|
||||
socketPath: '/fake/preserved-socket',
|
||||
tokenPath: '/fake/preserved-token'
|
||||
}))
|
||||
lifecycleLeaseErrors.push(new Error('preserved lease failed'))
|
||||
|
||||
await expect(mod.initDaemonPtyProvider()).rejects.toThrow('preserved lease failed')
|
||||
|
||||
expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce()
|
||||
expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
|
||||
expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled()
|
||||
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => {
|
||||
const mod = await importFresh()
|
||||
let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void
|
||||
|
||||
@@ -7,8 +7,7 @@ import { createAdapter } from '../../routing/testing/daemon-pty-router-test-fixt
|
||||
import {
|
||||
disconnectDaemon,
|
||||
listLiveDaemonSessions,
|
||||
replaceDaemonProvider,
|
||||
requestIdleDaemonRetirement
|
||||
replaceDaemonProvider
|
||||
} from '../daemon-provider-state'
|
||||
import { PROTOCOL_VERSION } from '../../../protocol/daemon-protocol-version'
|
||||
|
||||
@@ -16,11 +15,6 @@ 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)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import '../../pty-subprocess/testing/mock-descendant-sweep'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HeadlessEmulator } from '../../emulation/headless-emulator'
|
||||
|
||||
// This fixture assembles the payload locally; it does not exercise the adapter’s assembly.
|
||||
function buildReattachPayload(snapshot: ReturnType<HeadlessEmulator['getSnapshot']>) {
|
||||
const isAltScreen = snapshot.modes.alternateScreen
|
||||
const fullPayload = snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||||
return {
|
||||
rehydrateSequences: snapshot.rehydrateSequences,
|
||||
snapshotAnsi: snapshot.snapshotAnsi,
|
||||
fullPayload,
|
||||
isAlternateScreen: isAltScreen,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows
|
||||
}
|
||||
}
|
||||
|
||||
describe('reattach snapshot flow', () => {
|
||||
describe('alternate-screen TUI (Codex style)', () => {
|
||||
it('returns to preserved shell history after the reattached TUI exits', async () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 10 })
|
||||
await emulator.write('shell output before Codex\r\n$ codex')
|
||||
await emulator.write('\x1b[?1049h\x1b[2J\x1b[HCodex TUI content')
|
||||
const payload = buildReattachPayload(emulator.getSnapshot())
|
||||
emulator.dispose()
|
||||
|
||||
const replay = new HeadlessEmulator({ cols: 80, rows: 10 })
|
||||
try {
|
||||
await replay.write(payload.fullPayload)
|
||||
expect(replay.getVisibleLines().join('\n')).toContain('Codex TUI content')
|
||||
await replay.write('\x1b[?1049l')
|
||||
expect(replay.getVisibleLines().join('\n')).toContain('shell output before Codex')
|
||||
} finally {
|
||||
replay.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { once } from 'node:events'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inspectDaemonProcessIdentity } from '../endpoint/identity/daemon-pid-identity'
|
||||
|
||||
const namingModule = new URL('../daemon-process-name.ts', import.meta.url)
|
||||
|
||||
describe.skipIf(process.platform !== 'linux')('Linux daemon process naming', () => {
|
||||
it.each([false, true])(
|
||||
'preserves live daemon identity when naming writes are denied: %s',
|
||||
async (denyWrites) => {
|
||||
const socketPath = '/tmp/orca naming contract/daemon.sock'
|
||||
const tokenPath = `/tmp/orca naming contract/${'long endpoint component/'.repeat(12)}daemon.token`
|
||||
const script = `
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { setDaemonProcessName } from ${JSON.stringify(namingModule.href)}
|
||||
const before = readFileSync('/proc/self/cmdline').toString('base64')
|
||||
const beforeName = readFileSync('/proc/self/comm').toString('base64')
|
||||
setDaemonProcessName()
|
||||
process.stdin.resume()
|
||||
console.log([
|
||||
before,
|
||||
readFileSync('/proc/self/cmdline').toString('base64'),
|
||||
beforeName,
|
||||
readFileSync('/proc/self/comm').toString('base64')
|
||||
].join(' '))
|
||||
`
|
||||
const child = spawnProcess({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [
|
||||
...(denyWrites ? ['--permission', '--allow-fs-read=*'] : []),
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
script,
|
||||
'--',
|
||||
'daemon-entry.js',
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath
|
||||
]
|
||||
})
|
||||
const exited = once(child, 'exit')
|
||||
const lines = createInterface({ input: child.stdout })
|
||||
try {
|
||||
const [line] = await Promise.race([
|
||||
once(lines, 'line'),
|
||||
exited.then(() => {
|
||||
throw new Error('Naming fixture exited before publishing its process identity')
|
||||
})
|
||||
])
|
||||
const [before, after, beforeName, afterName] = String(line).split(' ')
|
||||
expect(Buffer.from(before, 'base64').toString()).toContain(tokenPath)
|
||||
expect(after).toBe(before)
|
||||
expect(Buffer.from(afterName, 'base64').toString()).toBe(
|
||||
denyWrites ? Buffer.from(beforeName, 'base64').toString() : 'orca-agents\n'
|
||||
)
|
||||
expect(child.pid).toBeDefined()
|
||||
expect(
|
||||
await inspectDaemonProcessIdentity(child.pid ?? 0, socketPath, tokenPath, null)
|
||||
).toBe('match')
|
||||
} finally {
|
||||
lines.close()
|
||||
child.stdin.end()
|
||||
await exited
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,15 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Callback fixtures exercise native execFile; Mac spawn capture has real-child coverage.
|
||||
const restorePlatform = vi.hoisted(() => {
|
||||
const platform = process.platform
|
||||
if (platform === 'darwin') {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
}
|
||||
return () => Object.defineProperty(process, 'platform', { value: platform })
|
||||
})
|
||||
afterAll(restorePlatform)
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
|
||||
vi.mock('node:child_process', () => ({
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as GitFetchHeadLockModule from '../../../shared/git/fetch/git-fetch-head-lock'
|
||||
|
||||
// Callback fixtures exercise native execFile; Mac spawn capture has real-child coverage.
|
||||
const restorePlatform = vi.hoisted(() => {
|
||||
const platform = process.platform
|
||||
if (platform === 'darwin') {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
}
|
||||
return () => Object.defineProperty(process, 'platform', { value: platform })
|
||||
})
|
||||
afterAll(restorePlatform)
|
||||
|
||||
const {
|
||||
execFileMock,
|
||||
spawnMock,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync, type SpawnOptions } from 'node:child_process'
|
||||
import type { SpawnOptions } from 'node:child_process'
|
||||
import { execNativeFileSync } from '@orca/process-host/native-process-spawn'
|
||||
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
|
||||
import { untranslatedGitOutputEnv } from './git-environment/git-process-env'
|
||||
import { resolveCommand } from './host-routing/wsl-command-resolution'
|
||||
@@ -28,17 +29,18 @@ export function gitExecFileSync(
|
||||
// Host Git only: a WSL path would make this a synchronous wsl.exe that can boot the distro.
|
||||
throw new Error('Synchronous Git cannot run in a WSL folder. Use asynchronous Git for it.')
|
||||
}
|
||||
const spawnStartedAt = performance.now()
|
||||
try {
|
||||
return execFileSync(resolved.binary, resolved.args, {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A BufferEncoding is always supplied, so execFileSync returns text.
|
||||
return execNativeFileSync(
|
||||
resolved.binary,
|
||||
resolved.args,
|
||||
{
|
||||
cwd: resolved.cwd,
|
||||
encoding: options.encoding ?? 'utf-8',
|
||||
env: untranslatedGitOutputEnv(),
|
||||
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
|
||||
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
}) as string
|
||||
} finally {
|
||||
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
|
||||
}
|
||||
},
|
||||
recordSubprocessSpawn
|
||||
) as string
|
||||
}
|
||||
|
||||
@@ -27,11 +27,7 @@ import {
|
||||
} from '../../../shared/git/commands/git-command-timeout'
|
||||
import { classifyGitCommand } from '../../../shared/git/commands/git-command-classification'
|
||||
import { createAbortError } from './process-capture/abort-error'
|
||||
import {
|
||||
readCapturedGitBuffer,
|
||||
readCapturedGitString,
|
||||
withCapturedErrorStdout
|
||||
} from './process-capture/captured-git-output'
|
||||
import { readCapturedGitBuffer, readCapturedGitString } from './process-capture/captured-git-output'
|
||||
import { runUnlessGitCwdKnownMissing } from './host-routing/missing-git-spawn-cwd'
|
||||
import { trackGitWorktreeMutation } from './git-worktree-mutation-state'
|
||||
import { trackGitRefMutation } from './git-ref-mutation-state'
|
||||
@@ -45,7 +41,7 @@ async function gitExecFileAsyncUnlocked(
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
// Trace caller intent rather than the resolved binary so WSL and native commands group together.
|
||||
return withGitSpan(
|
||||
{ args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) },
|
||||
{ args, cwd: options.cwd, admissionTier: options.admissionTier },
|
||||
async (span) => {
|
||||
if (options.onStderr && options.terminationBarrier) {
|
||||
throw new Error('onStderr is not supported with terminationBarrier.')
|
||||
@@ -106,7 +102,6 @@ async function gitExecFileAsyncUnlocked(
|
||||
env: policy.env,
|
||||
signal: options.signal,
|
||||
terminationBarrier: options.terminationBarrier,
|
||||
requireGuestTerminationProof: options.requireGuestTerminationProof,
|
||||
admissionTier: options.admissionTier,
|
||||
spawnOnWorkerThread: options.spawnOnWorkerThread,
|
||||
onChildTerminated: reportTerminated,
|
||||
@@ -142,6 +137,9 @@ async function gitExecFileAsyncUnlocked(
|
||||
) {
|
||||
await terminationState.current
|
||||
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
|
||||
if (options.canStart?.() === false) {
|
||||
throw createAbortError()
|
||||
}
|
||||
const fallback = resolveGitCommand(args, effectiveOptions, true, true)
|
||||
result = await capture(fallback)
|
||||
// Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient.
|
||||
@@ -185,6 +183,16 @@ async function gitExecFileAsyncUnlocked(
|
||||
)
|
||||
}
|
||||
|
||||
/** Callers parse stdout of non-zero exits (check-ignore exit 1), so strip the fence there too. */
|
||||
function withCapturedErrorStdout(error: unknown, command: ResolvedCommand): unknown {
|
||||
if (command.captured && error && typeof error === 'object' && 'stdout' in error) {
|
||||
if (typeof error.stdout === 'string') {
|
||||
error.stdout = readCapturedGitString(error.stdout, command)
|
||||
}
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
export function gitExecFileAsync(args: string[], callerOptions: GitExecOptions) {
|
||||
const options = routeLocalGitOptions(callerOptions)
|
||||
const releaseMutation = trackGitWorktreeMutation(args, options)
|
||||
@@ -220,7 +228,8 @@ async function gitExecFileAsyncBufferInSpan(
|
||||
args: string[],
|
||||
options: GitBufferExecOptions
|
||||
): Promise<{ stdout: Buffer }> {
|
||||
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
|
||||
const meta = { args, cwd: options.cwd, admissionTier: options.admissionTier }
|
||||
return withGitSpan(meta, async (span) => {
|
||||
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
|
||||
if (checkoutRoutingReady) {
|
||||
await checkoutRoutingReady
|
||||
|
||||
@@ -23,8 +23,6 @@ export type GitExecOptions = {
|
||||
preferWslDirectGit?: boolean
|
||||
useConfiguredSshCommandForNetwork?: boolean
|
||||
terminationBarrier?: boolean
|
||||
/** Destructive WSL checkout removal must retain ownership until guest death is proved. */
|
||||
requireGuestTerminationProof?: true
|
||||
observeChildTermination?: (terminated: Promise<void>, writes: boolean) => void
|
||||
/** Scheduler priority for this child; status is the safe default. */
|
||||
admissionTier?: GitAdmissionTier
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
|
||||
import type { ChildProcess, SpawnOptions } from 'node:child_process'
|
||||
import { spawnNativeProcess } from '@orca/process-host/native-process-spawn'
|
||||
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
|
||||
import { startGitSpan } from '../../observability/instrumentation'
|
||||
import { createAbortError } from './process-capture/abort-error'
|
||||
@@ -69,7 +70,7 @@ export async function withGitAdmission(
|
||||
if (admission) {
|
||||
await admission
|
||||
}
|
||||
const span = startGitSpan({ args, cwd: options.cwd })
|
||||
const span = startGitSpan({ args, cwd: options.cwd, admissionTier: options.admissionTier })
|
||||
let grant: Awaited<ReturnType<typeof acquireGitAdmission>> | null = null
|
||||
try {
|
||||
grant = await acquireGitAdmission({
|
||||
@@ -163,13 +164,16 @@ export function gitSpawn(args: string[], callerOptions: GitSpawnOptions): ChildP
|
||||
...(wslDistro ? { wslDistro } : {}),
|
||||
...(spawnOptions.env ? { env: spawnOptions.env } : {})
|
||||
})
|
||||
const spawnStartedAt = performance.now()
|
||||
const child = spawn(resolved.binary, resolved.args, {
|
||||
...spawnOptions,
|
||||
env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env),
|
||||
windowsHide: true,
|
||||
cwd: resolved.cwd
|
||||
})
|
||||
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
|
||||
return child
|
||||
return spawnNativeProcess(
|
||||
resolved.binary,
|
||||
resolved.args,
|
||||
{
|
||||
...spawnOptions,
|
||||
env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env),
|
||||
windowsHide: true,
|
||||
cwd: resolved.cwd
|
||||
},
|
||||
undefined,
|
||||
recordSubprocessSpawn
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ function streamInSpan(
|
||||
maxBuffer: number,
|
||||
timeoutMs: number | undefined
|
||||
): Promise<GitStreamResult> {
|
||||
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
|
||||
const meta = { args, cwd: options.cwd, admissionTier: options.admissionTier }
|
||||
return withGitSpan(meta, async (span) => {
|
||||
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
|
||||
if (checkoutRoutingReady) {
|
||||
await checkoutRoutingReady
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import * as processHost from '@orca/process-host'
|
||||
import type { ProcessTerminationBarrier } from '@orca/process-host/process-spec'
|
||||
import {
|
||||
getLocalWorktreePathAccess,
|
||||
removeLocalWorktreePath
|
||||
} from '../../../workspaces/removal/local-worktree-filesystem'
|
||||
import { removeWorktree } from '../../worktrees/removal/worktree-removal'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFakeSpawnedChild } from '../../../../shared/__fixtures__/fake-spawned-child'
|
||||
|
||||
@@ -167,180 +160,6 @@ describe('WSL direct Git reads', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['generic', false],
|
||||
['git', true],
|
||||
['git', false],
|
||||
['filesystem', true],
|
||||
['filesystem', false]
|
||||
] as const)(
|
||||
'E1 retains %s removal until guest termination is proved: confirmed=%s',
|
||||
async (kind, confirmed) => {
|
||||
await withPlatform('win32', async () => {
|
||||
vi.useFakeTimers()
|
||||
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
|
||||
const command = createMockChild()
|
||||
let guestConfirmed = confirmed
|
||||
let barrier: ProcessTerminationBarrier | undefined
|
||||
const runProcess = processHost.runProcess
|
||||
const processSeam = vi
|
||||
.spyOn(processHost, 'runProcess')
|
||||
.mockImplementation((spec, capture) => {
|
||||
if (spec.terminationBarrier && typeof spec.terminationBarrier === 'object') {
|
||||
barrier ??= spec.terminationBarrier
|
||||
}
|
||||
return runProcess(spec, capture)
|
||||
})
|
||||
command.kill.mockImplementation(() => {
|
||||
queueMicrotask(() => command.emit('close', null, 'SIGKILL'))
|
||||
return true
|
||||
})
|
||||
spawnMock.mockImplementation((_program, args) => {
|
||||
if (spawnMock.mock.calls.length === 1) {
|
||||
queueMicrotask(() => {
|
||||
const marker = String(args?.join(' ')).match(
|
||||
/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/
|
||||
)?.[1]
|
||||
command.stderr.emit('data', Buffer.from(`${marker}4321\n`))
|
||||
})
|
||||
return command
|
||||
}
|
||||
const terminator = createMockChild()
|
||||
queueMicrotask(() => {
|
||||
const running = args?.includes('--running')
|
||||
if (running) {
|
||||
terminator.stdout.emit('data', Buffer.from(`${DISTRO}\n`))
|
||||
}
|
||||
terminator.emit('close', running || guestConfirmed ? 0 : 1, null)
|
||||
})
|
||||
return terminator
|
||||
})
|
||||
const controller = new AbortController()
|
||||
let terminated = false
|
||||
let settled = false
|
||||
const pending =
|
||||
kind === 'generic'
|
||||
? gitExecFileAsync(['rebase', 'upstream/main'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
env: { GIT_CONFIG_GLOBAL: '/home/user/custom.gitconfig' },
|
||||
wslDistro: DISTRO,
|
||||
timeout: 25,
|
||||
terminationBarrier: true,
|
||||
observeChildTermination: (ended) => {
|
||||
void ended.then(() => {
|
||||
terminated = true
|
||||
})
|
||||
}
|
||||
})
|
||||
: kind === 'filesystem'
|
||||
? removeLocalWorktreePath(
|
||||
String.raw`C:\repo\feature`,
|
||||
{ wslDistro: DISTRO },
|
||||
controller.signal
|
||||
)
|
||||
: removeWorktree(String.raw`C:\repo`, String.raw`C:\repo\feature`, false, {
|
||||
wslDistro: DISTRO,
|
||||
deleteBranch: false,
|
||||
knownRemovedWorktree: { branch: 'feature', head: 'abc' },
|
||||
checkoutDeleteSignal: controller.signal,
|
||||
observeChildTermination: (ended) => {
|
||||
void ended.then(() => {
|
||||
terminated = true
|
||||
})
|
||||
}
|
||||
})
|
||||
const outcome = pending.then(
|
||||
() => {
|
||||
settled = true
|
||||
},
|
||||
(error: unknown) => {
|
||||
settled = true
|
||||
return error
|
||||
}
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
|
||||
await Promise.resolve()
|
||||
if (kind !== 'generic') {
|
||||
controller.abort()
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
if (kind === 'generic') {
|
||||
expect(await outcome).toMatchObject({ name: 'GitCommandTimeoutError', timeoutMs: 25 })
|
||||
expect(terminated).toBe(true)
|
||||
expect(settled).toBe(true)
|
||||
expect(command.kill).toHaveBeenCalled()
|
||||
} else if (confirmed) {
|
||||
expect(await outcome).toMatchObject({ name: 'AbortError' })
|
||||
if (kind === 'git') {
|
||||
expect(terminated).toBe(true)
|
||||
}
|
||||
expect(settled).toBe(true)
|
||||
} else {
|
||||
expect(terminated).toBe(false)
|
||||
expect(settled).toBe(false)
|
||||
expect(command.kill).toHaveBeenCalled()
|
||||
// Release the fixture's held owner with independent guest proof, never shim closure.
|
||||
guestConfirmed = true
|
||||
if (!barrier) {
|
||||
throw new Error('guest barrier missing')
|
||||
}
|
||||
await barrier.force(command)
|
||||
expect(await outcome).toMatchObject({ name: 'AbortError' })
|
||||
}
|
||||
expect(spawnMock.mock.calls[0]?.[1]?.join(' ')).toContain('setsid --wait')
|
||||
} finally {
|
||||
processSeam.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])(
|
||||
'waits beyond30s for WSL filesystem deletion to finish with checkout signal=%s',
|
||||
async (withSignal) => {
|
||||
await withPlatform('win32', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const command = createMockChild()
|
||||
spawnMock.mockReturnValue(command)
|
||||
let finished = false
|
||||
const controller = new AbortController()
|
||||
const pending = removeLocalWorktreePath(
|
||||
String.raw`C:\repo\feature`,
|
||||
{ wslDistro: DISTRO },
|
||||
withSignal ? controller.signal : undefined
|
||||
).then(() => {
|
||||
finished = true
|
||||
})
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(31_000)
|
||||
expect(finished).toBe(false)
|
||||
expect(command.kill).not.toHaveBeenCalled()
|
||||
command.emit('close', 0, null)
|
||||
await pending
|
||||
expect(finished).toBe(true)
|
||||
if (!withSignal) {
|
||||
const read = createMockChild()
|
||||
read.kill.mockImplementation(() => {
|
||||
queueMicrotask(() => read.emit('close', null, 'SIGTERM'))
|
||||
return true
|
||||
})
|
||||
spawnMock.mockReturnValue(read)
|
||||
const rejected = expect(
|
||||
getLocalWorktreePathAccess({ wslDistro: DISTRO }).statPath('/mnt/c/repo/missing')
|
||||
).rejects.toThrow('timed out after 30000ms')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await rejected
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('stops waiting for a wedged probe and reads through the shell route', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -28,13 +28,3 @@ export function readCapturedGitString(stdout: string, resolved: ResolvedCommand)
|
||||
const endIndex = stdout.indexOf(captured.endMarker, payloadStart)
|
||||
return endIndex === -1 ? stdout.slice(payloadStart) : stdout.slice(payloadStart, endIndex)
|
||||
}
|
||||
|
||||
/** Callers parse stdout of non-zero exits (check-ignore exit 1), so strip the fence there too. */
|
||||
export function withCapturedErrorStdout(error: unknown, command: ResolvedCommand): unknown {
|
||||
if (command.captured && error && typeof error === 'object' && 'stdout' in error) {
|
||||
if (typeof error.stdout === 'string') {
|
||||
error.stdout = readCapturedGitString(error.stdout, command)
|
||||
}
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises'
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nativeExecutableResolver } from '@orca/process-host/native-executable-resolution'
|
||||
import * as nativeSpawn from '@orca/process-host/native-process-spawn'
|
||||
import { beginLocalCommandSelection } from '../../../ipc/command-path/command-path-resolver'
|
||||
import { execFileCapture } from './exec-file-capture'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('Mac capture during executable lookup', () => {
|
||||
it.each(['abort', 'deadline'] as const)(
|
||||
'settles %s before lookup completes without launching late',
|
||||
async (reason) => {
|
||||
let release!: (selection: { program: string; file: string }) => void
|
||||
vi.spyOn(nativeExecutableResolver, 'resolveAsync').mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const spawn = vi.spyOn(nativeSpawn, 'spawnNativeProcess')
|
||||
const controller = new AbortController()
|
||||
const terminated = vi.fn()
|
||||
const deadline = vi.fn()
|
||||
const pending = execFileCapture(process.execPath, ['-e', 'throw Error("launched late")'], {
|
||||
signal: controller.signal,
|
||||
...(reason === 'deadline' ? { timeout: 1 } : {}),
|
||||
onChildTerminated: terminated,
|
||||
onDeadlineKill: deadline
|
||||
})
|
||||
const rejected = expect(pending).rejects.toThrow(reason === 'abort' ? 'aborted' : 'timed out')
|
||||
if (reason === 'abort') {
|
||||
controller.abort()
|
||||
}
|
||||
await rejected
|
||||
release({ program: process.execPath, file: process.execPath })
|
||||
await nextTurn()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(terminated).toHaveBeenCalledOnce()
|
||||
expect(deadline).toHaveBeenCalledTimes(reason === 'deadline' ? 1 : 0)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the preflight-selected CLI ahead of an earlier executable', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-capture-selection-'))
|
||||
roots.push(root)
|
||||
const first = join(root, 'first')
|
||||
const second = join(root, 'second')
|
||||
await Promise.all([mkdir(first), mkdir(second)])
|
||||
const earlier = join(first, 'gh')
|
||||
const selected = join(second, 'gh')
|
||||
await Promise.all([
|
||||
writeFile(earlier, '#!/bin/sh\necho wrong\n'),
|
||||
symlink(process.execPath, selected)
|
||||
])
|
||||
await chmod(earlier, 0o755)
|
||||
vi.stubEnv('PATH', `${first}:${second}`)
|
||||
const publish = beginLocalCommandSelection('gh')
|
||||
try {
|
||||
await publish(selected)
|
||||
await expect(
|
||||
execFileCapture('gh', ['-p', 'process.argv0'], { timeout: 2_000 })
|
||||
).resolves.toEqual({
|
||||
stdout: 'gh\n',
|
||||
stderr: ''
|
||||
})
|
||||
} finally {
|
||||
await publish(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves raw buffers and stderr observation on a failed command', async () => {
|
||||
const observed: string[] = []
|
||||
await expect(
|
||||
execFileCapture(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
'process.stdout.write(Buffer.from([0,255,128]));process.stderr.write("diagnostic");process.exitCode=7'
|
||||
],
|
||||
{
|
||||
encoding: 'buffer',
|
||||
onStderr: (chunk) => observed.push(chunk),
|
||||
timeout: 2_000
|
||||
}
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 7,
|
||||
stdout: Buffer.from([0, 255, 128]),
|
||||
stderr: Buffer.from('diagnostic')
|
||||
})
|
||||
expect(observed.join('')).toBe('diagnostic')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')('capture output overflow termination', () => {
|
||||
it('keeps the deadline and termination obligation alive when the child ignores the overflow signal', async () => {
|
||||
const original = nativeSpawn.spawnNativeProcess
|
||||
let child: ReturnType<typeof original> | undefined
|
||||
vi.spyOn(nativeSpawn, 'spawnNativeProcess').mockImplementation((...args) => {
|
||||
child = original(...args)
|
||||
return child
|
||||
})
|
||||
let reportTermination!: () => void
|
||||
const terminated = new Promise<void>((resolve) => {
|
||||
reportTermination = resolve
|
||||
})
|
||||
const onTerminated = vi.fn(reportTermination)
|
||||
const signals: string[] = []
|
||||
try {
|
||||
await expect(
|
||||
execFileCapture(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
let signals = 0;
|
||||
process.on('SIGTERM', () => {
|
||||
if (++signals === 1) process.stderr.write('first');
|
||||
else process.stderr.write('second', () => process.exit(0));
|
||||
});
|
||||
setInterval(() => {}, 1000);
|
||||
process.stdout.write('x'.repeat(64));
|
||||
`
|
||||
],
|
||||
{
|
||||
maxBuffer: 32,
|
||||
timeout: 3_000,
|
||||
onChildTerminated: onTerminated,
|
||||
onStderr: (chunk) => signals.push(chunk)
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('timed out')
|
||||
await terminated
|
||||
expect(onTerminated).toHaveBeenCalledOnce()
|
||||
expect(signals.join('')).toBe('firstsecond')
|
||||
} finally {
|
||||
// The test owns this exact returned child, including when the regression assertion fails.
|
||||
if (child?.pid && child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,22 +2,22 @@ import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_pr
|
||||
import { recordSubprocessSpawn } from '../../../diagnostics/main-thread-churn-probe'
|
||||
import { endSubprocessStdin } from '../../../../shared/subprocess-stdin-write'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { nativeExecutableResolver } from '@orca/process-host/native-executable-resolution'
|
||||
import { spawnNativeProcess } from '@orca/process-host/native-process-spawn'
|
||||
import { resolveSelectedLocalCommand } from '../../../ipc/command-path/command-path-resolver'
|
||||
import type { WslProcessGroupTermination } from './wsl-process-group-termination'
|
||||
import { createWslGuestTerminationProof } from './wsl-guest-termination-proof'
|
||||
import { createAbortError } from './abort-error'
|
||||
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
|
||||
import { DEFAULT_GIT_MAX_BUFFER } from '../git-exec-options'
|
||||
import type { GitAdmissionTier } from '../../../../shared/rpc-contract/source-control/git-admission-tier-params'
|
||||
|
||||
import { workerExecFile } from './worker-exec-file'
|
||||
import { spawnedExecFile, workerExecFile } from './worker-exec-file'
|
||||
import type { WorkerThreadChildProcess } from '../../../worker-thread-child-process/worker-thread-child-process'
|
||||
|
||||
type ExecFileCaptureOptions = Omit<ExecFileOptions, 'timeout'> & {
|
||||
timeout?: number
|
||||
stdin?: string
|
||||
terminationBarrier?: boolean
|
||||
requireGuestTerminationProof?: true
|
||||
onChildTerminated?: () => void
|
||||
admissionTier?: GitAdmissionTier
|
||||
createTimeoutError?: () => Error
|
||||
@@ -36,10 +36,6 @@ export async function execFileCaptureToTermination(
|
||||
options: Omit<ExecFileCaptureOptions, 'onStderr' | 'stdin'> & { stdin?: string | Uint8Array },
|
||||
termination?: WslProcessGroupTermination
|
||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
|
||||
if (options.signal?.aborted) {
|
||||
options.onChildTerminated?.()
|
||||
throw createAbortError()
|
||||
}
|
||||
// runProcess already reports spawn cost through spawnProcess.
|
||||
let program: string
|
||||
try {
|
||||
@@ -52,13 +48,6 @@ export async function execFileCaptureToTermination(
|
||||
throw error
|
||||
}
|
||||
const captureBytes = options.encoding === 'buffer'
|
||||
const proof =
|
||||
termination && options.requireGuestTerminationProof
|
||||
? createWslGuestTerminationProof(termination)
|
||||
: undefined
|
||||
if (proof) {
|
||||
void proof.terminated.then(() => options.onChildTerminated?.())
|
||||
}
|
||||
const pending = runProcess({
|
||||
program,
|
||||
args,
|
||||
@@ -69,12 +58,11 @@ export async function execFileCaptureToTermination(
|
||||
captureStdoutAsBytes: captureBytes,
|
||||
captureStderrAsBytes: captureBytes,
|
||||
signal: options.signal,
|
||||
terminationBarrier: proof?.barrier ?? termination ?? true,
|
||||
onChildTerminated: proof?.onChildTerminated ?? options.onChildTerminated,
|
||||
terminationBarrier: termination ?? true,
|
||||
onChildTerminated: options.onChildTerminated,
|
||||
...(options.stdin === undefined ? {} : { input: options.stdin })
|
||||
})
|
||||
const result = await pending.finally(() => proof?.terminated)
|
||||
const aborted = !!options.signal?.aborted && (!proof || proof.stopping)
|
||||
const result = await pending
|
||||
const stdout = captureBytes ? (result.stdoutBytes ?? Buffer.alloc(0)) : result.stdout
|
||||
const stderrBytes = result.stderrBytes ?? Buffer.alloc(0)
|
||||
const stderr = captureBytes
|
||||
@@ -83,29 +71,34 @@ export async function execFileCaptureToTermination(
|
||||
Buffer.from(termination.stripControlOutput(stderrBytes.toString('latin1')), 'latin1')
|
||||
: stderrBytes
|
||||
: (termination?.stripControlOutput(result.stderr) ?? result.stderr)
|
||||
if (result.code === 0 && !result.timedOut && !result.outputTruncated && !aborted) {
|
||||
if (
|
||||
result.code === 0 &&
|
||||
!result.timedOut &&
|
||||
!result.outputTruncated &&
|
||||
!options.signal?.aborted
|
||||
) {
|
||||
return { stdout, stderr }
|
||||
}
|
||||
if (result.timedOut && !aborted) {
|
||||
if (result.timedOut && !options.signal?.aborted) {
|
||||
options.onDeadlineKill?.()
|
||||
}
|
||||
const cleanStderr = typeof stderr === 'string' ? stderr : stderr.toString('utf8')
|
||||
const error = result.timedOut
|
||||
? (options.createTimeoutError?.() ?? new Error(`${command} timed out.`))
|
||||
: new Error(
|
||||
aborted
|
||||
options.signal?.aborted
|
||||
? 'The operation was aborted.'
|
||||
: result.outputTruncated
|
||||
? // Reject clipped output: JSON/JSONL consumers could otherwise accept an incomplete result.
|
||||
`${command} produced more than ${options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER} bytes of output.`
|
||||
: cleanStderr.trim() || `${command} exited with ${result.code}.`
|
||||
)
|
||||
if (aborted) {
|
||||
if (options.signal?.aborted) {
|
||||
error.name = 'AbortError'
|
||||
}
|
||||
throw Object.assign(error, {
|
||||
code: result.code,
|
||||
killed: result.timedOut || result.signal !== null || aborted,
|
||||
killed: result.timedOut || result.signal !== null || options.signal?.aborted === true,
|
||||
signal: result.signal,
|
||||
outputTruncated: result.outputTruncated,
|
||||
stdout,
|
||||
@@ -162,6 +155,8 @@ export function execFileCapture(
|
||||
let terminating = false
|
||||
let child: ChildProcess | WorkerThreadChildProcess | null = null
|
||||
const useWorker = options.spawnOnWorkerThread === true && process.platform === 'darwin'
|
||||
const deadline =
|
||||
options.timeout && options.timeout > 0 ? performance.now() + options.timeout : null
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
let terminationReported = false
|
||||
const reportChildTerminated = (): void => {
|
||||
@@ -197,39 +192,59 @@ export function execFileCapture(
|
||||
}
|
||||
resolve({ stdout, stderr })
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
const stop = (error: Error): void => {
|
||||
if (settled || terminating) {
|
||||
return
|
||||
}
|
||||
terminating = true
|
||||
const abortError = createAbortError()
|
||||
if (!child) {
|
||||
terminating = false
|
||||
finish(abortError)
|
||||
reportChildTerminated()
|
||||
finish(error)
|
||||
return
|
||||
}
|
||||
void killSpawnedCommandTree(child).then(() => {
|
||||
terminating = false
|
||||
finish(abortError)
|
||||
finish(error)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const spawnStartedAt = performance.now()
|
||||
// Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children.
|
||||
const capture = useWorker ? workerExecFile : execFile
|
||||
child = capture(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
const onAbort = (): void => stop(createAbortError())
|
||||
const onDeadline = (): void => {
|
||||
if (!settled && !terminating) {
|
||||
options.onDeadlineKill?.()
|
||||
stop(options.createTimeoutError?.() ?? new Error(`${command} timed out.`))
|
||||
}
|
||||
}
|
||||
// Lookup is part of the deadline; cancellation must settle even before there is a child.
|
||||
if (options.timeout && options.timeout > 0) {
|
||||
timer = setTimeout(onDeadline, options.timeout)
|
||||
}
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (options.signal?.aborted) {
|
||||
onAbort()
|
||||
}
|
||||
|
||||
const launch = async (): Promise<void> => {
|
||||
try {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
const spawnStartedAt = performance.now()
|
||||
// Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children.
|
||||
const captureOptions = {
|
||||
cwd: options.cwd,
|
||||
// Hide console-subsystem children so frequent Git calls cannot flash or take focus.
|
||||
windowsHide: true,
|
||||
encoding: options.encoding,
|
||||
maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
|
||||
env: options.env
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
}
|
||||
const complete = (
|
||||
error: Error | null,
|
||||
stdout: string | Buffer,
|
||||
stderr: string | Buffer
|
||||
): void => {
|
||||
if (terminating) {
|
||||
return
|
||||
}
|
||||
@@ -239,57 +254,67 @@ export function execFileCapture(
|
||||
}
|
||||
finish(error, stdout, stderr)
|
||||
}
|
||||
)
|
||||
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
|
||||
} catch (error) {
|
||||
reportChildTerminated()
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
if (!useWorker && process.platform === 'darwin') {
|
||||
const program = resolveSelectedLocalCommand(command, {
|
||||
env: options.env,
|
||||
cwd: typeof options.cwd === 'string' ? options.cwd : undefined
|
||||
})
|
||||
const selection = await nativeExecutableResolver.resolveAsync(program, options.env)
|
||||
if (deadline !== null && performance.now() >= deadline) {
|
||||
onDeadline()
|
||||
}
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
child = spawnedExecFile(command, captureOptions, complete, () =>
|
||||
spawnNativeProcess(
|
||||
program,
|
||||
args,
|
||||
{ ...captureOptions, argv0: command },
|
||||
selection,
|
||||
recordSubprocessSpawn
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const capture = useWorker ? workerExecFile : execFile
|
||||
child = capture(command, args, captureOptions, complete)
|
||||
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
|
||||
}
|
||||
} catch (error) {
|
||||
reportChildTerminated()
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
|
||||
child.once('error', (error) => {
|
||||
// A lost worker may have spawned a child whose PID never reached us; only close proves termination.
|
||||
if (!useWorker && !child?.pid) {
|
||||
child.once('error', (error) => {
|
||||
// A lost worker may have spawned a child whose PID never reached us; only close proves termination.
|
||||
if (!useWorker && !child?.pid) {
|
||||
reportChildTerminated()
|
||||
}
|
||||
if (!terminating) {
|
||||
finish(error)
|
||||
}
|
||||
})
|
||||
child.once('close', reportChildTerminated)
|
||||
if (options.onStderr) {
|
||||
observeStderr(child, options.onStderr)
|
||||
}
|
||||
|
||||
// Non-interactive signers must see EOF instead of waiting on an open pipe.
|
||||
if ('id' in child) {
|
||||
if (options.stdin !== undefined) {
|
||||
child.stdin.write(options.stdin)
|
||||
}
|
||||
child.stdin.end()
|
||||
} else {
|
||||
endSubprocessStdin(child.stdin, options.stdin ?? '')
|
||||
}
|
||||
}
|
||||
void launch().catch((error: unknown) => {
|
||||
if (!child) {
|
||||
reportChildTerminated()
|
||||
}
|
||||
if (!terminating) {
|
||||
finish(error)
|
||||
}
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
child.once('close', reportChildTerminated)
|
||||
if (options.onStderr) {
|
||||
observeStderr(child, options.onStderr)
|
||||
}
|
||||
|
||||
// Non-interactive signers must see EOF instead of waiting on an open pipe.
|
||||
if ('id' in child) {
|
||||
if (options.stdin !== undefined) {
|
||||
child.stdin.write(options.stdin)
|
||||
}
|
||||
child.stdin.end()
|
||||
} else {
|
||||
endSubprocessStdin(child.stdin, options.stdin ?? '')
|
||||
}
|
||||
|
||||
// Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup.
|
||||
if (options.timeout && options.timeout > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (settled || terminating) {
|
||||
return
|
||||
}
|
||||
terminating = true
|
||||
const timeoutError = options.createTimeoutError?.() ?? new Error(`${command} timed out.`)
|
||||
if (!child) {
|
||||
terminating = false
|
||||
finish(timeoutError)
|
||||
return
|
||||
}
|
||||
void killSpawnedCommandTree(child).then(() => {
|
||||
terminating = false
|
||||
finish(timeoutError)
|
||||
})
|
||||
}, options.timeout)
|
||||
}
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,33 +1,54 @@
|
||||
import { createOutputSink } from '@orca/process-host/bounded-output-sink'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnProcessOnWorkerThread } from '../../../worker-thread-child-process/worker-thread-child-process'
|
||||
import type { WorkerThreadChildProcess } from '../../../worker-thread-child-process/worker-thread-child-process'
|
||||
|
||||
type CaptureOptions = {
|
||||
cwd?: string | URL
|
||||
env?: NodeJS.ProcessEnv
|
||||
encoding?: string | null
|
||||
maxBuffer: number
|
||||
}
|
||||
type CaptureCallback = (
|
||||
error: Error | null,
|
||||
stdout: string | Buffer,
|
||||
stderr: string | Buffer
|
||||
) => void
|
||||
|
||||
/** Capture a worker-owned child; the caller still owns deadlines and aborts. */
|
||||
export function workerExecFile(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
cwd?: string | URL
|
||||
env?: NodeJS.ProcessEnv
|
||||
encoding?: string | null
|
||||
maxBuffer: number
|
||||
},
|
||||
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void
|
||||
options: CaptureOptions,
|
||||
callback: CaptureCallback
|
||||
): WorkerThreadChildProcess {
|
||||
return spawnedExecFile(command, options, callback, () =>
|
||||
spawnProcessOnWorkerThread({
|
||||
program: command,
|
||||
args,
|
||||
cwd: options.cwd instanceof URL ? fileURLToPath(options.cwd) : options.cwd,
|
||||
env: options.env
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Capture either host's real child using the same byte limits and completion contract. */
|
||||
export function spawnedExecFile<T extends ChildProcess | WorkerThreadChildProcess>(
|
||||
command: string,
|
||||
options: CaptureOptions,
|
||||
callback: CaptureCallback,
|
||||
launch: () => T
|
||||
): T {
|
||||
const encoding = options.encoding === null ? 'buffer' : (options.encoding ?? 'utf8')
|
||||
if (encoding !== 'buffer' && !Buffer.isEncoding(encoding)) {
|
||||
throw new TypeError(`Unsupported Git output encoding: ${encoding}`)
|
||||
throw new TypeError(`Unsupported output encoding: ${encoding}`)
|
||||
}
|
||||
const child = spawnProcessOnWorkerThread({
|
||||
program: command,
|
||||
args,
|
||||
cwd: options.cwd instanceof URL ? fileURLToPath(options.cwd) : options.cwd,
|
||||
env: options.env
|
||||
})
|
||||
const child = launch()
|
||||
const stdout = createOutputSink(options.maxBuffer)
|
||||
const stderr = createOutputSink(options.maxBuffer)
|
||||
let completed = false
|
||||
let overflowError: Error | null = null
|
||||
const finish = (error: Error | null): void => {
|
||||
if (completed) {
|
||||
return
|
||||
@@ -44,28 +65,27 @@ export function workerExecFile(
|
||||
return
|
||||
}
|
||||
sink.write(chunk)
|
||||
if (sink.truncated()) {
|
||||
if (sink.truncated() && !overflowError) {
|
||||
overflowError = Object.assign(new Error(`${command} output exceeded maxBuffer`), {
|
||||
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
||||
killed: true
|
||||
})
|
||||
child.kill()
|
||||
finish(
|
||||
Object.assign(new Error('Git output exceeded maxBuffer'), {
|
||||
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
||||
killed: true
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
child.stdout.on('data', (chunk: Buffer) => collect(stdout, chunk))
|
||||
child.stderr.on('data', (chunk: Buffer) => collect(stderr, chunk))
|
||||
child.stdout?.on('data', (chunk: Buffer) => collect(stdout, chunk))
|
||||
child.stderr?.on('data', (chunk: Buffer) => collect(stderr, chunk))
|
||||
child.once('error', finish)
|
||||
child.once('close', (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
finish(
|
||||
code === 0
|
||||
? null
|
||||
: Object.assign(new Error(stderr.text().trim() || `${command} exited with ${code}`), {
|
||||
code,
|
||||
signal,
|
||||
killed: signal !== null
|
||||
})
|
||||
overflowError ??
|
||||
(code === 0
|
||||
? null
|
||||
: Object.assign(new Error(stderr.text().trim() || `${command} exited with ${code}`), {
|
||||
code,
|
||||
signal,
|
||||
killed: signal !== null
|
||||
}))
|
||||
)
|
||||
})
|
||||
return child
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { WslProcessGroupTermination } from './wsl-process-group-termination'
|
||||
|
||||
/** Root wsl.exe closure cannot prove an interrupted guest command has stopped. */
|
||||
export function createWslGuestTerminationProof(group: WslProcessGroupTermination) {
|
||||
let stopping = false
|
||||
let complete!: () => void
|
||||
const terminated = new Promise<void>((resolve) => {
|
||||
complete = resolve
|
||||
})
|
||||
const observe = async (child: ChildProcess, stop: () => Promise<boolean>): Promise<boolean> => {
|
||||
stopping = true
|
||||
if (!child.pid) {
|
||||
complete()
|
||||
return true
|
||||
}
|
||||
const proved = await stop()
|
||||
if (proved) {
|
||||
complete()
|
||||
}
|
||||
return proved
|
||||
}
|
||||
return {
|
||||
terminated,
|
||||
get stopping() {
|
||||
return stopping
|
||||
},
|
||||
onChildTerminated: () => {
|
||||
if (!stopping) {
|
||||
complete()
|
||||
}
|
||||
},
|
||||
barrier: {
|
||||
...group,
|
||||
signal: (...args: Parameters<typeof group.signal>) =>
|
||||
observe(args[0], () => group.signal(...args)),
|
||||
force: (...args: Parameters<typeof group.force>) =>
|
||||
observe(args[0], () => group.force(...args))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFakeSpawnedChild as createMockChildProcess } from '../../../shared/__fixtures__/fake-spawned-child'
|
||||
|
||||
// Callback fixtures exercise native execFile; Mac spawn capture has real-child coverage.
|
||||
const restorePlatform = vi.hoisted(() => {
|
||||
const platform = process.platform
|
||||
if (platform === 'darwin') {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
}
|
||||
return () => Object.defineProperty(process, 'platform', { value: platform })
|
||||
})
|
||||
afterAll(restorePlatform)
|
||||
|
||||
const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
|
||||
@@ -5,12 +5,15 @@ import { resolveDefaultBaseRefViaExec } from '../../../../shared/git/refs/git-de
|
||||
export type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
admissionTier?: GitAdmissionTier
|
||||
/** Checked at admission; a bounded optional read refuses git that would start past its deadline. */
|
||||
canStart?: () => boolean
|
||||
}
|
||||
|
||||
export type LocalDefaultBaseRefGitOptions = {
|
||||
cwd: string
|
||||
wslDistro?: string
|
||||
admissionTier?: GitAdmissionTier
|
||||
canStart?: () => boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000
|
||||
@@ -22,7 +25,8 @@ export function gitExecOptions(
|
||||
return {
|
||||
cwd,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
|
||||
...(options.admissionTier ? { admissionTier: options.admissionTier } : {}),
|
||||
...(options.canStart ? { canStart: options.canStart } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { BranchRefSnapshotStore, BranchSnapshotIdentityChanged } from './branch-ref-snapshot-store'
|
||||
import { subscribeGitRefChanges } from '../git-ref-change-signal'
|
||||
import { readTranslatedWorktreeGraph } from '../../worktrees/catalog/worktree-list-reader'
|
||||
import { withRepositoryGitCause } from '../../repository-git-cause'
|
||||
|
||||
const snapshots = new BranchRefSnapshotStore({ subscribe: subscribeGitRefChanges })
|
||||
|
||||
@@ -16,6 +17,19 @@ async function withIdentity<T>(
|
||||
path: string,
|
||||
git: LocalGitExecOptions,
|
||||
read: (identity: BranchSnapshotIdentity) => Promise<T>
|
||||
): Promise<T> {
|
||||
return withRepositoryGitCause(
|
||||
path,
|
||||
git,
|
||||
{ family: 'refs', reason: 'branch-snapshot', cache: 'uncached' },
|
||||
() => readWithIdentity(path, git, read)
|
||||
)
|
||||
}
|
||||
|
||||
async function readWithIdentity<T>(
|
||||
path: string,
|
||||
git: LocalGitExecOptions,
|
||||
read: (identity: BranchSnapshotIdentity) => Promise<T>
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const identity = await resolveBranchSnapshotIdentity(path, git)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { deriveOriginStandInRemoteName } from '../../../shared/git/remotes/git-remote-identity'
|
||||
import { gitExecFileAsync } from '../command-runner/git-exec-file'
|
||||
import { listCachedRemoteNames, type RemoteNameListingGitOptions } from './remote-name-listing'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from './remote-url-probe'
|
||||
|
||||
async function readOriginStandInRemoteName(
|
||||
repoPath: string,
|
||||
connectionId: string | null | undefined,
|
||||
localGitOptions: RemoteNameListingGitOptions
|
||||
): Promise<string | null> {
|
||||
const names = await listCachedRemoteNames(repoPath, connectionId, localGitOptions)
|
||||
if (!names?.some((name) => name !== 'origin' && name !== 'upstream')) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['remote', '-v'], {
|
||||
cwd: repoPath,
|
||||
timeout: REMOTE_URL_PROBE_TIMEOUT_MS,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
||||
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
|
||||
})
|
||||
return deriveOriginStandInRemoteName(stdout)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Origin's GitHub role falls to the remote project identity reads when origin is missing or a local path. */
|
||||
export async function resolveOriginWithStandIn<T>(
|
||||
repoPath: string,
|
||||
connectionId: string | null | undefined,
|
||||
localGitOptions: RemoteNameListingGitOptions,
|
||||
resolveRemote: (remoteName: string) => Promise<T | null>
|
||||
): Promise<T | null> {
|
||||
const origin = await resolveRemote('origin')
|
||||
if (origin !== null) {
|
||||
return origin
|
||||
}
|
||||
const standIn = await readOriginStandInRemoteName(repoPath, connectionId, localGitOptions)
|
||||
return standIn ? resolveRemote(standIn) : null
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { resolveWorktreeAddBaseRef } from '../../../../shared/worktree/base-refs
|
||||
type GitExecOptions = {
|
||||
wslDistro?: string
|
||||
admissionTier?: GitAdmissionTier
|
||||
canStart?: () => boolean
|
||||
}
|
||||
|
||||
/** Return the probe's commit OID so callers need not resolve it again. */
|
||||
|
||||
@@ -90,14 +90,7 @@ async function performRemoveWorktree(
|
||||
}
|
||||
const execOptions = {
|
||||
...finalizationOptions,
|
||||
...(options.checkoutDeleteSignal
|
||||
? {
|
||||
signal: options.checkoutDeleteSignal,
|
||||
...(process.platform === 'win32' && (options.wslDistro || parseWslPath(repoPath))
|
||||
? { terminationBarrier: true, requireGuestTerminationProof: true as const }
|
||||
: {})
|
||||
}
|
||||
: {})
|
||||
...(options.checkoutDeleteSignal ? { signal: options.checkoutDeleteSignal } : {})
|
||||
}
|
||||
const args = [...longPathArgs, 'worktree', 'remove']
|
||||
if (force) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { resolveIssueGitHubApiRepositorySource } from './github-api-repository'
|
||||
import { resolvePrWorkItemSource } from '../client/list/work-item-list-request'
|
||||
import { installRealWorkerThreadChildProcesses } from '../../worker-thread-child-process/worker-thread-child-process-real.test-fixtures'
|
||||
|
||||
let directory: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (directory) {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
directory = null
|
||||
}
|
||||
})
|
||||
|
||||
async function createRepo(remotes: [name: string, url: (repo: string) => string][]) {
|
||||
const repo = await mkdtemp(join(tmpdir(), 'orca-origin-stand-in-'))
|
||||
directory = repo
|
||||
const git = async (args: string[]) => {
|
||||
const result = await runProcess({ program: 'git', args, cwd: repo })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr)
|
||||
}
|
||||
}
|
||||
await git(['init', '-q'])
|
||||
await git(['init', '--bare', '-q', join(repo, 'mirror.git')])
|
||||
for (const [name, url] of remotes) {
|
||||
await git(['remote', 'add', name, url(repo)])
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
it('reads GitHub from the remote standing in for an origin that is a local path (#42102)', async () => {
|
||||
const repo = await createRepo([
|
||||
['origin', (root) => join(root, 'mirror.git')],
|
||||
['github', () => 'https://github.com/acme/widgets.git']
|
||||
])
|
||||
const expected = { owner: 'acme', repo: 'widgets', host: 'github.com' }
|
||||
|
||||
expect((await resolveIssueGitHubApiRepositorySource(repo, undefined)).source).toEqual(expected)
|
||||
expect((await resolvePrWorkItemSource(repo, undefined)).source).toEqual(expected)
|
||||
})
|
||||
|
||||
installRealWorkerThreadChildProcesses()
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from './github-repository-identity'
|
||||
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../shared/github/repositories/repository-identity'
|
||||
import { shouldProbeGitRemote } from '../../git/remotes/remote-name-listing'
|
||||
import { resolveOriginWithStandIn } from '../../git/remotes/origin-stand-in-remote'
|
||||
|
||||
import {
|
||||
getEnterpriseGitHubRepoSlug,
|
||||
@@ -128,10 +129,14 @@ export async function getGitHubApiRepositoryForRemote(
|
||||
})
|
||||
}
|
||||
|
||||
/** The repo origin names; a project whose origin is missing or a local path reads its stand-in remote. */
|
||||
export async function getOriginGitHubApiRepository(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
probeOptions: GitHubRemoteIdentityProbeOptions = {}
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
return resolveOriginWithStandIn(repoPath, connectionId, localGitOptions, (name) =>
|
||||
getGitHubApiRepositoryForRemote(repoPath, name, connectionId, localGitOptions, probeOptions)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ vi.mock('./github-enterprise-repository', async (importOriginal) => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../git/remotes/remote-name-listing', () => ({
|
||||
shouldProbeGitRemote: shouldProbeGitRemoteMock
|
||||
shouldProbeGitRemote: shouldProbeGitRemoteMock,
|
||||
listCachedRemoteNames: async () => null
|
||||
}))
|
||||
import {
|
||||
_resetOriginGitHubApiRepositoryCache,
|
||||
|
||||
@@ -41,9 +41,8 @@ export async function getIssueGitHubApiRepository(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
return withGitHubRepoExecution(repoPath, connectionId, localGitOptions, async () => {
|
||||
const originPromise = getGitHubApiRepositoryForRemote(
|
||||
const originPromise = getOriginGitHubApiRepository(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
).then(
|
||||
@@ -81,15 +80,9 @@ export async function resolveGitHubApiRepositoryCandidates(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepositoryCandidates> {
|
||||
return withGitHubRepoExecution(repoPath, connectionId, localGitOptions, async () => {
|
||||
const originPromise = getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
{
|
||||
requireVerifiedSshProbe: true
|
||||
}
|
||||
).then(
|
||||
const originPromise = getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions, {
|
||||
requireVerifiedSshProbe: true
|
||||
}).then(
|
||||
(value) => ({ status: 'fulfilled' as const, value }),
|
||||
(reason: unknown) => ({ status: 'rejected' as const, reason })
|
||||
)
|
||||
@@ -151,22 +144,12 @@ export async function resolveIssueGitHubApiRepositorySource(
|
||||
if (upstream) {
|
||||
return { source: upstream, fellBack: false }
|
||||
}
|
||||
const origin = await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
return { source: origin, fellBack: origin !== null }
|
||||
}
|
||||
if (preference === 'origin') {
|
||||
return {
|
||||
source: await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
),
|
||||
source: await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
fellBack: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS = 2_000
|
||||
export const HANG_WATCHDOG_TIMEOUT_MS = 45_000
|
||||
export const HANG_WATCHDOG_CHECK_INTERVAL_MS = 5_000
|
||||
import type { NativeHangProcessMetadata } from './native-hang-report'
|
||||
|
||||
export type HangWatchdogWorkerData = {
|
||||
parentPid: number
|
||||
@@ -8,12 +9,15 @@ export type HangWatchdogWorkerData = {
|
||||
timeoutMs: number
|
||||
checkIntervalMs: number
|
||||
tracePath?: string
|
||||
artifactDirectory?: string
|
||||
processMetadata?: NativeHangProcessMetadata
|
||||
}
|
||||
|
||||
export type MainToHangWatchdogWorkerMessage =
|
||||
| { type: 'heartbeat' }
|
||||
| { type: 'shutdown' }
|
||||
| { type: 'capture-drain-ack'; token: string }
|
||||
| { type: 'menu-bar-setting'; enabled: boolean }
|
||||
| { type: 'power-event'; event: HangPowerReceipt['event']; observedAt: number }
|
||||
import type { HangPowerReceipt } from './hang-observations'
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ const state = vi.hoisted(() => ({
|
||||
clock: 0,
|
||||
delayedWrite: false,
|
||||
rejectRunner: false,
|
||||
parkedStop: false,
|
||||
successfulNative: false,
|
||||
artifacts: new Array<{ showMenuBarIcon: boolean | null }>(),
|
||||
commands: new Array<string>(),
|
||||
terminations: new Array<() => void>(),
|
||||
records: new Array<unknown>()
|
||||
@@ -14,8 +17,16 @@ vi.mock('node:inspector', () => ({
|
||||
Session: class {
|
||||
connectToMainThread() {}
|
||||
disconnect() {}
|
||||
post(method: string, _params: unknown, response: (error: null, result?: unknown) => void) {
|
||||
post(
|
||||
method: string,
|
||||
_params: unknown,
|
||||
response: (error: Error | null, result?: unknown) => void
|
||||
) {
|
||||
state.commands.push(method)
|
||||
if (method === 'Profiler.stop' && state.parkedStop) {
|
||||
response(new Error('uncertain inspector transport'))
|
||||
return
|
||||
}
|
||||
response(
|
||||
null,
|
||||
method === 'Profiler.stop'
|
||||
@@ -31,9 +42,22 @@ vi.mock('@orca/process-host', () => ({
|
||||
if (state.rejectRunner) {
|
||||
throw new Error('runner rejected after possible acquisition')
|
||||
}
|
||||
if (state.successfulNative) {
|
||||
options.onChildTerminated()
|
||||
return { stdout: 'native evidence', stderr: '', code: 0, timedOut: false }
|
||||
}
|
||||
return { stdout: '', stderr: '', code: null, timedOut: true }
|
||||
}
|
||||
}))
|
||||
vi.mock('./native-hang-capture-store', () => ({
|
||||
createNativeHangCaptureId: (time: number) => `hang-${time}`,
|
||||
NativeHangCaptureStore: class {
|
||||
async save(artifact: { showMenuBarIcon: boolean | null }) {
|
||||
state.artifacts.push(structuredClone(artifact))
|
||||
}
|
||||
async recover() {}
|
||||
}
|
||||
}))
|
||||
vi.mock('./hang-detection-marker', () => ({
|
||||
readAvailableHangDetectionMarker: () => null,
|
||||
writeHangDetectionMarker: (_path: string, record: unknown) => {
|
||||
@@ -53,6 +77,9 @@ beforeEach(() => {
|
||||
state.clock = 0
|
||||
state.delayedWrite = false
|
||||
state.rejectRunner = false
|
||||
state.parkedStop = false
|
||||
state.successfulNative = false
|
||||
state.artifacts = []
|
||||
state.commands = []
|
||||
state.terminations = []
|
||||
state.records = []
|
||||
@@ -75,6 +102,15 @@ function watchdog() {
|
||||
{
|
||||
parentPid: 123,
|
||||
markerPath: '/qa/main-thread-hang.json',
|
||||
artifactDirectory: '/qa/captures',
|
||||
processMetadata: {
|
||||
appVersion: '1',
|
||||
electronVersion: '43',
|
||||
chromeVersion: '150',
|
||||
arch: 'arm64',
|
||||
osRelease: '25',
|
||||
osVersion: 'Darwin'
|
||||
},
|
||||
timeoutMs: 100,
|
||||
checkIntervalMs: 25
|
||||
},
|
||||
@@ -124,7 +160,7 @@ it.each([false, true])(
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
await vi.dynamicImportSettled()
|
||||
expect(state.records.at(-1)).toMatchObject({
|
||||
observations: [expect.anything(), expect.not.objectContaining({ mainJs: expect.anything() })]
|
||||
observations: [expect.anything(), expect.not.objectContaining({ native: expect.anything() })]
|
||||
})
|
||||
owner.send({ type: 'heartbeat' })
|
||||
state.terminations.shift()?.()
|
||||
@@ -153,3 +189,42 @@ it.each([false, true])(
|
||||
owner.send({ type: 'shutdown' })
|
||||
}
|
||||
)
|
||||
|
||||
it('captures a later native episode while the earlier inspector cleanup remains uncertain', async () => {
|
||||
state.parkedStop = true
|
||||
const owner = watchdog()
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
await vi.dynamicImportSettled()
|
||||
state.terminations.shift()?.()
|
||||
owner.send({ type: 'heartbeat' })
|
||||
await vi.dynamicImportSettled()
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
await vi.dynamicImportSettled()
|
||||
expect(state.records.at(-1)).toMatchObject({
|
||||
observations: [
|
||||
expect.objectContaining({ mainJs: expect.objectContaining({ state: 'failed' }) }),
|
||||
expect.objectContaining({ native: expect.objectContaining({ state: 'completed' }) })
|
||||
]
|
||||
})
|
||||
owner.send({ type: 'shutdown' })
|
||||
})
|
||||
|
||||
it('snapshots only the setting actually received before each episode', async () => {
|
||||
state.successfulNative = true
|
||||
const owner = watchdog()
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
await vi.dynamicImportSettled()
|
||||
owner.send({ type: 'heartbeat' })
|
||||
owner.send({ type: 'menu-bar-setting', enabled: false })
|
||||
await vi.dynamicImportSettled()
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
await vi.dynamicImportSettled()
|
||||
owner.send({ type: 'menu-bar-setting', enabled: true })
|
||||
expect(state.artifacts.map((item) => item.showMenuBarIcon)).toEqual([null, false])
|
||||
owner.send({ type: 'heartbeat' })
|
||||
await vi.dynamicImportSettled()
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
await vi.dynamicImportSettled()
|
||||
expect(state.artifacts.map((item) => item.showMenuBarIcon)).toEqual([null, false, true])
|
||||
owner.send({ type: 'shutdown' })
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type HangPowerReceipt
|
||||
} from './hang-observations'
|
||||
import { captureHangSnapshot } from './hang-snapshot'
|
||||
import { createWatchdogHangCapture } from './watchdog-hang-capture'
|
||||
import type {
|
||||
HangWatchdogWorkerData,
|
||||
MainToHangWatchdogWorkerMessage
|
||||
@@ -22,13 +23,6 @@ type HangWatchdogPort = {
|
||||
close: () => void
|
||||
postMessage?: (message: { type: 'capture-drain'; token: string }) => void
|
||||
}
|
||||
type CaptureOwner = {
|
||||
id: string
|
||||
reason?: 'recovery' | 'shutdown'
|
||||
stopJs?: (reason: 'recovery' | 'shutdown') => void
|
||||
stopNative?: () => void
|
||||
done?: Promise<void>
|
||||
}
|
||||
|
||||
export function runWatchdog(
|
||||
config: HangWatchdogWorkerData,
|
||||
@@ -43,7 +37,7 @@ export function runWatchdog(
|
||||
let sequence = 0
|
||||
let activeStallId: string | null = null
|
||||
let stopped = false
|
||||
let captureOwner: CaptureOwner | null = null
|
||||
let showMenuBarIcon: boolean | null = null
|
||||
let drain: { token: string; acknowledge: () => void } | null = null
|
||||
let collector: AbortController | null = null
|
||||
let recentPower: HangPowerReceipt[] = []
|
||||
@@ -80,55 +74,18 @@ export function runWatchdog(
|
||||
observations = retainHangObservation(observations, { ...record, ...change })
|
||||
save()
|
||||
}
|
||||
const capture = (record: HangObservation, detectedAt: number): void => {
|
||||
if (isMainThread || captureOwner || stopped || record.kind !== 'heartbeat-stall') {
|
||||
return
|
||||
}
|
||||
const owner: CaptureOwner = { id: record.id }
|
||||
captureOwner = owner
|
||||
owner.done = (async () => {
|
||||
const [inspector, js, native] = await Promise.all([
|
||||
import('node:inspector'),
|
||||
import('./main-js-hang-capture'),
|
||||
import('./native-hang-capture')
|
||||
])
|
||||
const mainJs = js.captureMainJsHang({
|
||||
session: new inspector.Session(),
|
||||
detectedAt,
|
||||
now: () => performance.now(),
|
||||
publish: (mainJs) => update(record.id, { mainJs }),
|
||||
drain: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
drain = { token: record.id, acknowledge: resolve }
|
||||
port.postMessage?.({ type: 'capture-drain', token: record.id })
|
||||
})
|
||||
})
|
||||
owner.stopJs = mainJs.stop
|
||||
if (owner.reason) {
|
||||
mainJs.stop(owner.reason)
|
||||
}
|
||||
const nativeCapture =
|
||||
!owner.reason && process.platform === 'darwin'
|
||||
? native.captureNativeHang({
|
||||
pid: config.parentPid,
|
||||
publish: (native) => update(record.id, { native })
|
||||
})
|
||||
: null
|
||||
owner.stopNative = nativeCapture?.stop
|
||||
await Promise.all([mainJs.done, nativeCapture?.done])
|
||||
})()
|
||||
.catch(() => {
|
||||
// A published baseline remains useful if an optional collector cannot initialize.
|
||||
})
|
||||
.finally(() => {
|
||||
if (captureOwner === owner) {
|
||||
captureOwner = null
|
||||
}
|
||||
if (stopped) {
|
||||
port.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
const captures = createWatchdogHangCapture({
|
||||
parentPid: config.parentPid,
|
||||
artifactDirectory: config.artifactDirectory,
|
||||
processMetadata: config.processMetadata,
|
||||
publish: update,
|
||||
drain: (token) =>
|
||||
new Promise<void>((resolve) => {
|
||||
drain = { token, acknowledge: resolve }
|
||||
port.postMessage?.({ type: 'capture-drain', token })
|
||||
}),
|
||||
onIdle: () => port.close()
|
||||
})
|
||||
const openReceiptWindow = (id: string): void => {
|
||||
receiptWindow = { id, until: Date.now() + config.checkIntervalMs, remaining: 4 }
|
||||
}
|
||||
@@ -204,7 +161,9 @@ export function runWatchdog(
|
||||
}
|
||||
openReceiptWindow(record.id)
|
||||
save()
|
||||
capture(record, detectedAt)
|
||||
if (!isMainThread) {
|
||||
captures.capture(record, detectedAt, showMenuBarIcon)
|
||||
}
|
||||
enrich(record)
|
||||
}
|
||||
const loop = createHangWatchdogDetectionLoop({
|
||||
@@ -218,10 +177,7 @@ export function runWatchdog(
|
||||
}
|
||||
const id = activeStallId
|
||||
activeStallId = null
|
||||
if (captureOwner?.id === id) {
|
||||
captureOwner.reason ??= 'recovery'
|
||||
captureOwner.stopJs?.('recovery')
|
||||
}
|
||||
captures.recover(id)
|
||||
update(id, { selfRecovered: true, resolution: captureHangSnapshot() })
|
||||
openReceiptWindow(id)
|
||||
},
|
||||
@@ -254,6 +210,8 @@ export function runWatchdog(
|
||||
}
|
||||
if (message.type === 'heartbeat') {
|
||||
loop.recordHeartbeat()
|
||||
} else if (message.type === 'menu-bar-setting') {
|
||||
showMenuBarIcon = message.enabled
|
||||
} else if (message.type === 'power-event') {
|
||||
const receipt = parsePowerReceipt({ ...message, receivedAt: Date.now() })
|
||||
if (!receipt) {
|
||||
@@ -281,13 +239,7 @@ export function runWatchdog(
|
||||
receiptWindow = null
|
||||
clearInterval(checkTimer)
|
||||
collector?.abort()
|
||||
if (captureOwner) {
|
||||
captureOwner.reason ??= 'shutdown'
|
||||
captureOwner.stopJs?.('shutdown')
|
||||
captureOwner.stopNative?.()
|
||||
} else {
|
||||
port.close()
|
||||
}
|
||||
captures.shutdown()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import os from 'node:os'
|
||||
import { spawnWorkerThread } from '../worker-threads/worker-thread-spawn'
|
||||
import { app, powerMonitor } from 'electron'
|
||||
import { hangDetectionMarkerPath } from './hang-detection-marker'
|
||||
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
|
||||
import { getTraceFilePath, resolveObservabilityConsent } from '../observability'
|
||||
import { nativeHangCaptureDirectory } from './native-hang-capture-store'
|
||||
import {
|
||||
HANG_WATCHDOG_CHECK_INTERVAL_MS,
|
||||
HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
export type MainThreadHangWatchdogHandle = {
|
||||
stop: () => void
|
||||
worker: Worker
|
||||
setMenuBarIconSetting: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
function positiveTiming(value: string | undefined, fallback: number): number {
|
||||
@@ -41,6 +44,15 @@ export function installMainThreadHangWatchdog(options: {
|
||||
parentPid: process.pid,
|
||||
markerPath: hangDetectionMarkerPath(options.userDataPath),
|
||||
tracePath: getTraceFilePath(),
|
||||
artifactDirectory: nativeHangCaptureDirectory(options.userDataPath),
|
||||
processMetadata: {
|
||||
appVersion: app.getVersion(),
|
||||
electronVersion: process.versions.electron ?? 'unknown',
|
||||
chromeVersion: process.versions.chrome ?? 'unknown',
|
||||
arch: os.arch(),
|
||||
osRelease: os.release(),
|
||||
osVersion: os.version()
|
||||
},
|
||||
timeoutMs: positiveTiming(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS, HANG_WATCHDOG_TIMEOUT_MS),
|
||||
checkIntervalMs: positiveTiming(
|
||||
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS,
|
||||
@@ -117,5 +129,9 @@ export function installMainThreadHangWatchdog(options: {
|
||||
})
|
||||
worker.unref()
|
||||
app.on('will-quit', stop)
|
||||
return { stop, worker }
|
||||
return {
|
||||
stop,
|
||||
worker,
|
||||
setMenuBarIconSetting: (enabled) => postMessage({ type: 'menu-bar-setting', enabled })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { CrashReportStore } from '../crash-reporting/reports/crash-report-store'
|
||||
import { createPreviousNativeHangReport } from './previous-native-hang-report'
|
||||
import {
|
||||
NativeHangCaptureStore,
|
||||
nativeHangCaptureDirectory,
|
||||
readNativeHangCapture,
|
||||
readPreviousNativeHangCapture
|
||||
} from './native-hang-capture-store'
|
||||
import { sanitizeNativeHangReport, type NativeHangArtifact } from './native-hang-report'
|
||||
import { readPreviousNativeHangTelemetry } from './previous-native-hang-telemetry'
|
||||
import { mainThreadHangDetectedSchema } from '../../shared/telemetry-daemon-event-schemas'
|
||||
import { getLatestPendingReport } from '../ipc/crash-reporting/crash-reporting-sendable-reports'
|
||||
import { collectBundle } from '../observability/bundle'
|
||||
|
||||
const sample = `Sampling process 123 for 1 second with 10 milliseconds of run time between samples
|
||||
Process: Orca [123]
|
||||
Path: /Users/Test User/Secret Project/Orca.app/Contents/MacOS/Orca
|
||||
Analysis Tool: /Users/Test User/Private Tools/sample
|
||||
OS Version: macOS 26.1 (25B78)
|
||||
Call graph:
|
||||
100 Thread_1 DispatchQueue_1: com.apple.main-thread (serial)
|
||||
+ 100 nativeWait (in Orca) + 12 [0x10000000c]
|
||||
100 Thread_42 background-worker
|
||||
+ 100 backgroundWork (in /Users/Test User/Private (Client List)/Worker) + 8 [0x100100008]
|
||||
Total number in stack (recursive counted multiple, when >=5):
|
||||
100 nativeWait
|
||||
password=fixture-private-value
|
||||
Binary Images:
|
||||
0x100000000 - 0x1000fffff Orca (1.2.3) <AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA> /Users/Test User/Secret Project/Orca.app/Contents/MacOS/Orca
|
||||
0x100100000 - 0x1001fffff Worker (4.5.6) <BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB> /Users/Test User/Private Project/Worker
|
||||
`
|
||||
|
||||
function artifact(time: number): NativeHangArtifact {
|
||||
return {
|
||||
version: 1,
|
||||
captureId: `hang-${time}-00000000-0000-0000-0000-${String(time).padStart(12, '0')}`,
|
||||
detectedAt: time,
|
||||
capturedAt: time + 1,
|
||||
pid: 123,
|
||||
showMenuBarIcon: null,
|
||||
process: {
|
||||
appVersion: '1.2.3',
|
||||
electronVersion: '43.8.6',
|
||||
chromeVersion: '150',
|
||||
arch: 'arm64',
|
||||
osRelease: '25.1.0',
|
||||
osVersion: 'Darwin Kernel Version 25.1.0'
|
||||
},
|
||||
report: sample
|
||||
}
|
||||
}
|
||||
|
||||
let directory: string
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime('2026-11-12T01:00:00.000Z')
|
||||
directory = await fs.mkdtemp(join(tmpdir(), 'orca-native-hang-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retains every sampled thread and image identity while removing full spaced paths and credentials', async () => {
|
||||
const store = new NativeHangCaptureStore(directory)
|
||||
await store.save(artifact(1))
|
||||
const capture = await readNativeHangCapture(directory)
|
||||
expect(capture?.artifact).toMatchObject({
|
||||
showMenuBarIcon: null,
|
||||
process: { appVersion: '1.2.3', electronVersion: '43.8.6' }
|
||||
})
|
||||
expect(capture?.artifact.report).toContain('Thread_42 background-worker')
|
||||
expect(capture?.artifact.report).toContain('backgroundWork (in Worker) + 8 [0x100100008]')
|
||||
expect(capture?.artifact.report).toContain('macOS 26.1 (25B78)')
|
||||
expect(capture?.artifact.report).toContain(
|
||||
'0x100100000 - 0x1001fffff Worker (4.5.6) <BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB>'
|
||||
)
|
||||
expect(capture?.artifact.report).not.toMatch(
|
||||
/Test User|Secret Project|Private Project|Client List|Private Tools|fixture-private-value/
|
||||
)
|
||||
expect((await fs.stat(capture!.path)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('withholds complete-artifact admission for a cut report or unsafe image row', () => {
|
||||
expect(sanitizeNativeHangReport(sample.trimEnd())).toBeNull()
|
||||
expect(sanitizeNativeHangReport(sample.replace('Binary Images:', 'Missing Images:'))).toBeNull()
|
||||
expect(sanitizeNativeHangReport(`${sample}unparsed /Users/Test User/private image\n`)).toBeNull()
|
||||
expect(sanitizeNativeHangReport(sample.replace('[0x100100008]', 'unknown location'))).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ count: 4, times: [1, 2, 3, 4] },
|
||||
{ count: 40, times: [37, 38, 39, 40] }
|
||||
])(
|
||||
'bounds retained evidence after $count incidents and preserves unrelated files',
|
||||
async ({ count, times }) => {
|
||||
const store = new NativeHangCaptureStore(directory)
|
||||
await fs.writeFile(join(directory, 'unrelated.txt'), 'keep')
|
||||
await fs.writeFile(
|
||||
join(directory, 'hang-0-00000000-0000-0000-0000-000000000000.tmp'),
|
||||
'interrupted'
|
||||
)
|
||||
for (let time = 1; time <= count; time++) {
|
||||
await store.save(artifact(time))
|
||||
}
|
||||
const names = await fs.readdir(directory)
|
||||
const retained: number[] = []
|
||||
for (const name of names.filter((name) => name.endsWith('.json'))) {
|
||||
retained.push(JSON.parse(await fs.readFile(join(directory, name), 'utf8')).detectedAt)
|
||||
}
|
||||
expect(retained.sort((a, b) => a - b)).toEqual(times)
|
||||
expect(names.filter((name) => name.endsWith('.tmp'))).toEqual([])
|
||||
expect(await fs.readFile(join(directory, 'unrelated.txt'), 'utf8')).toBe('keep')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps manual freeze reports silent and preserves identity and sent status across restarts', async () => {
|
||||
await new NativeHangCaptureStore(directory).save(artifact(1))
|
||||
const store = new CrashReportStore(join(directory, 'reports.json'))
|
||||
const previous = createPreviousNativeHangReport({ store, directory, processStartedAt: 100 })
|
||||
await Promise.all([previous.ensureImported(), previous.ensureImported()])
|
||||
const report = (await store.listRecent())[0]!
|
||||
expect(await getLatestPendingReport(store)).toBeNull()
|
||||
expect(report).toMatchObject({
|
||||
status: 'dismissed',
|
||||
details: { captureId: artifact(1).captureId, showMenuBarIcon: null }
|
||||
})
|
||||
await store.markDismissedSent(report.id)
|
||||
await createPreviousNativeHangReport({ store, directory, processStartedAt: 200 }).ensureImported()
|
||||
expect(await store.listRecent()).toMatchObject([{ id: report.id, status: 'sent' }])
|
||||
expect((await readPreviousNativeHangCapture(directory, 200))?.artifact.captureId).toBe(
|
||||
artifact(1).captureId
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an ordinary pending crash and imports freeze evidence on a later manual open', async () => {
|
||||
await new NativeHangCaptureStore(directory).save(artifact(1))
|
||||
const store = new CrashReportStore(join(directory, 'reports.json'))
|
||||
const original = await store.record({
|
||||
source: 'renderer',
|
||||
processType: 'renderer',
|
||||
reason: 'crashed',
|
||||
exitCode: 5,
|
||||
platform: 'darwin',
|
||||
appVersion: '1',
|
||||
electronVersion: '43',
|
||||
chromeVersion: '150',
|
||||
arch: 'arm64',
|
||||
osRelease: '25',
|
||||
details: {}
|
||||
})
|
||||
const previous = createPreviousNativeHangReport({ store, directory, processStartedAt: 100 })
|
||||
await previous.ensureImported()
|
||||
expect((await getLatestPendingReport(store))?.id).toBe(original.id)
|
||||
expect(await store.listRecent()).toHaveLength(1)
|
||||
await store.dismiss(original.id)
|
||||
await previous.ensureImported()
|
||||
expect((await store.listRecent())[0]).toMatchObject({
|
||||
reason: 'main-thread-hang',
|
||||
status: 'dismissed',
|
||||
details: { captureId: artifact(1).captureId }
|
||||
})
|
||||
expect(await getLatestPendingReport(store)).toBeNull()
|
||||
})
|
||||
|
||||
it('enriches only the matching freeze event with captured safe frames, build and setting', async () => {
|
||||
const captures = nativeHangCaptureDirectory(directory)
|
||||
await new NativeHangCaptureStore(captures).save({ ...artifact(1), showMenuBarIcon: false })
|
||||
await new NativeHangCaptureStore(captures).save({ ...artifact(2), showMenuBarIcon: true })
|
||||
const marker = { detectedAt: 1, parentPid: 123, unresponsiveMs: 50000, selfRecovered: false }
|
||||
const details = await readPreviousNativeHangTelemetry(directory, marker)
|
||||
expect(details).toEqual({
|
||||
native_top_frames: ['nativeWait [Orca]'],
|
||||
macos_build: 'macOS 26.1 (25B78)',
|
||||
menu_bar_icon: false
|
||||
})
|
||||
expect(
|
||||
mainThreadHangDetectedSchema.safeParse({
|
||||
unresponsive_ms: 50000,
|
||||
self_recovered: false,
|
||||
...details
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(await readPreviousNativeHangTelemetry(directory, { ...marker, parentPid: 999 })).toEqual(
|
||||
{}
|
||||
)
|
||||
expect(await readPreviousNativeHangTelemetry(directory, { ...marker, detectedAt: 3 })).toEqual({})
|
||||
})
|
||||
|
||||
it('selects the reported incident and never replaces missing evidence with a newer capture', async () => {
|
||||
const store = new NativeHangCaptureStore(directory)
|
||||
await store.save(artifact(1))
|
||||
await store.save(artifact(2))
|
||||
expect((await readNativeHangCapture(directory, artifact(1).captureId))?.artifact.detectedAt).toBe(
|
||||
1
|
||||
)
|
||||
expect((await readNativeHangCapture(directory))?.artifact.detectedAt).toBe(2)
|
||||
expect(await readNativeHangCapture(directory, artifact(3).captureId)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an intact large capture with both log sources inside the attachment limit', async () => {
|
||||
const trace = join(directory, 'main.trace')
|
||||
const daemon = join(directory, 'daemon.log')
|
||||
await fs.writeFile(
|
||||
trace,
|
||||
`${JSON.stringify({ name: 'important-main-span', ts: '2026-11-12T00:59:00.000Z', value: 't'.repeat(16_000) })}\n`
|
||||
)
|
||||
await fs.writeFile(
|
||||
daemon,
|
||||
Array.from({ length: 200 }, () =>
|
||||
JSON.stringify({ name: 'daemon-event', value: 'a'.repeat(8_000) })
|
||||
).join('\n')
|
||||
)
|
||||
const clean = sample.replace('password=fixture-private-value\n', '')
|
||||
const base = { type: 'native-hang-capture', status: 'complete', ...artifact(1), report: clean }
|
||||
const escapedPadding = Math.floor((3_145_600 - Buffer.byteLength(JSON.stringify(base))) / 2)
|
||||
const report = clean.replace(
|
||||
'nativeWait (in Orca)',
|
||||
`${'\\'.repeat(escapedPadding)}nativeWait (in Orca)`
|
||||
)
|
||||
|
||||
const bundle = collectBundle({
|
||||
traceFilePath: trace,
|
||||
maxFiles: 1,
|
||||
daemonLogFilePath: daemon,
|
||||
appVersion: '1',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
osRelease: '25',
|
||||
orcaChannel: 'dev',
|
||||
nativeHangRecord: { type: 'native-hang-capture', status: 'complete', ...artifact(1), report }
|
||||
})
|
||||
const lines = bundle.payload
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line))
|
||||
expect(lines.find((line) => line.type === 'native-hang-capture')?.report).toBe(report)
|
||||
expect(lines.some((line) => line.name === 'important-main-span')).toBe(true)
|
||||
expect(lines.some((line) => line.name === 'daemon-event')).toBe(true)
|
||||
expect(Buffer.byteLength(bundle.payload)).toBeLessThanOrEqual(4_194_304)
|
||||
expect(bundle.bytes).toBe(Buffer.byteLength(bundle.payload))
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import fs from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
MAX_NATIVE_HANG_ARTIFACT_BYTES,
|
||||
parseNativeHangArtifact,
|
||||
type NativeHangArtifact
|
||||
} from './native-hang-report'
|
||||
|
||||
const CAPTURE_ID = /^hang-(\d{1,16})-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/
|
||||
const CAPTURE_FILE = /^(hang-\d{1,16}-[\da-f-]{36})(\.(?:recovered|seen))?\.json$/
|
||||
type CaptureFile = { name: string; captureId: string; detectedAt: number }
|
||||
export type StoredNativeHangCapture = { artifact: NativeHangArtifact; path: string }
|
||||
|
||||
export function nativeHangCaptureDirectory(userDataPath: string): string {
|
||||
return join(userDataPath, 'hang-captures')
|
||||
}
|
||||
|
||||
export function createNativeHangCaptureId(detectedAt: number): string {
|
||||
return `hang-${detectedAt}-${randomUUID()}`
|
||||
}
|
||||
|
||||
function captureFile(name: string): CaptureFile | null {
|
||||
const match = CAPTURE_FILE.exec(name)
|
||||
const id = match && CAPTURE_ID.exec(match[1]!)
|
||||
return match && id ? { name, captureId: match[1]!, detectedAt: Number(id[1]) } : null
|
||||
}
|
||||
|
||||
async function entries(directory: string): Promise<string[] | null> {
|
||||
try {
|
||||
if (!(await fs.lstat(directory)).isDirectory()) {
|
||||
return null
|
||||
}
|
||||
const handle = await fs.opendir(directory)
|
||||
try {
|
||||
const names: string[] = []
|
||||
while (names.length < 64) {
|
||||
const entry = await handle.read()
|
||||
if (!entry) {
|
||||
return names
|
||||
}
|
||||
names.push(entry.name)
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function listNativeHangCaptures(directory: string): Promise<CaptureFile[]> {
|
||||
return ((await entries(directory)) ?? [])
|
||||
.map(captureFile)
|
||||
.filter((file): file is CaptureFile => file !== null)
|
||||
.sort((a, b) => b.detectedAt - a.detectedAt || b.name.localeCompare(a.name))
|
||||
}
|
||||
|
||||
async function readCaptureFile(
|
||||
directory: string,
|
||||
file: CaptureFile
|
||||
): Promise<StoredNativeHangCapture | null> {
|
||||
const filePath = join(directory, file.name)
|
||||
let handle: Awaited<ReturnType<typeof fs.open>> | undefined
|
||||
try {
|
||||
handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW)
|
||||
const stat = await handle.stat()
|
||||
if (!stat.isFile() || stat.size > MAX_NATIVE_HANG_ARTIFACT_BYTES) {
|
||||
return null
|
||||
}
|
||||
const bytes = Buffer.alloc(stat.size + 1)
|
||||
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0)
|
||||
if (bytesRead !== stat.size) {
|
||||
return null
|
||||
}
|
||||
const parsed: unknown = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'))
|
||||
const artifact = parseNativeHangArtifact(parsed)
|
||||
return artifact &&
|
||||
artifact.captureId === file.captureId &&
|
||||
artifact.detectedAt === file.detectedAt
|
||||
? { artifact, path: filePath }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
await handle?.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNativeHangCapture(
|
||||
directory: string,
|
||||
captureId?: string
|
||||
): Promise<StoredNativeHangCapture | null> {
|
||||
const files = captureId
|
||||
? CAPTURE_ID.test(captureId)
|
||||
? ['', '.seen', '.recovered'].map((state) => captureFile(`${captureId}${state}.json`)!)
|
||||
: []
|
||||
: await listNativeHangCaptures(directory)
|
||||
for (const file of files) {
|
||||
const capture = await readCaptureFile(directory, file)
|
||||
if (capture) {
|
||||
return capture
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function readPreviousNativeHangCapture(
|
||||
directory: string,
|
||||
processStartedAt: number
|
||||
): Promise<StoredNativeHangCapture | null> {
|
||||
for (const file of await listNativeHangCaptures(directory)) {
|
||||
if (file.detectedAt < processStartedAt) {
|
||||
const capture = await readCaptureFile(directory, file)
|
||||
if (capture) {
|
||||
return capture
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export class NativeHangCaptureStore {
|
||||
private busy = false
|
||||
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
async save(artifact: NativeHangArtifact): Promise<void> {
|
||||
if (this.busy || !CAPTURE_ID.test(artifact.captureId)) {
|
||||
return
|
||||
}
|
||||
this.busy = true
|
||||
const temporary = join(this.directory, `${artifact.captureId}.tmp`)
|
||||
try {
|
||||
const safe = parseNativeHangArtifact(artifact)
|
||||
if (!safe) {
|
||||
return
|
||||
}
|
||||
const payload = `${JSON.stringify(safe)}\n`
|
||||
if (Buffer.byteLength(payload) > MAX_NATIVE_HANG_ARTIFACT_BYTES) {
|
||||
return
|
||||
}
|
||||
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 })
|
||||
if (!(await fs.lstat(this.directory)).isDirectory()) {
|
||||
return
|
||||
}
|
||||
await fs.chmod(this.directory, 0o700)
|
||||
const names = await entries(this.directory)
|
||||
if (!names) {
|
||||
return
|
||||
}
|
||||
const captures = names
|
||||
.map(captureFile)
|
||||
.filter((file): file is CaptureFile => !!file)
|
||||
.sort((a, b) => b.detectedAt - a.detectedAt)
|
||||
for (const name of names) {
|
||||
if (name.endsWith('.tmp') && CAPTURE_ID.test(name.slice(0, -4))) {
|
||||
await fs.unlink(join(this.directory, name))
|
||||
}
|
||||
}
|
||||
for (const [index, file] of captures.entries()) {
|
||||
const stat = await fs.lstat(join(this.directory, file.name))
|
||||
if (index >= 3 || stat.size > MAX_NATIVE_HANG_ARTIFACT_BYTES || !stat.isFile()) {
|
||||
await fs.unlink(join(this.directory, file.name))
|
||||
}
|
||||
}
|
||||
await fs.writeFile(temporary, payload, { mode: 0o600, flag: 'wx' })
|
||||
await fs.rename(temporary, join(this.directory, `${artifact.captureId}.json`))
|
||||
} catch {
|
||||
// Optional full evidence must not interrupt marker capture or child cleanup.
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch(() => {})
|
||||
this.busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { runProcess as runNativeProcess } from '@orca/process-host'
|
||||
import { MAX_NATIVE_HANG_REPORT_BYTES } from './native-hang-report'
|
||||
import {
|
||||
emptyHangStacks,
|
||||
hangLabel,
|
||||
@@ -117,8 +118,13 @@ export function reduceNativeHangSample(
|
||||
export function captureNativeHang(options: {
|
||||
pid: number
|
||||
publish: (capture: NativeHangCapture) => void
|
||||
}): { stop: () => void; done: Promise<void> } {
|
||||
completeReport?: (report: string) => void
|
||||
}): { stop: () => void; started: Promise<void>; done: Promise<void> } {
|
||||
const abort = new AbortController()
|
||||
let acquired: () => void = () => {}
|
||||
const started = new Promise<void>((resolve) => {
|
||||
acquired = resolve
|
||||
})
|
||||
const baseline: NativeHangCapture = {
|
||||
...emptyHangStacks(),
|
||||
state: 'collecting',
|
||||
@@ -141,6 +147,7 @@ export function captureNativeHang(options: {
|
||||
runProcess = (await import('@orca/process-host')).runProcess
|
||||
} catch {
|
||||
publish({ ...baseline, state: 'failed', completedAt: Date.now(), cleanup: 'unverified' })
|
||||
acquired()
|
||||
return
|
||||
}
|
||||
let terminated = false
|
||||
@@ -149,11 +156,11 @@ export function captureNativeHang(options: {
|
||||
release = resolve
|
||||
})
|
||||
try {
|
||||
const result = await runProcess({
|
||||
const pending = runProcess({
|
||||
program: '/usr/bin/sample',
|
||||
args: [String(options.pid), '1', '10', '-file', '/dev/stdout'],
|
||||
timeoutMs: 2500,
|
||||
maxOutputBytes: 128 * 1024,
|
||||
timeoutMs: 10_000,
|
||||
maxOutputBytes: MAX_NATIVE_HANG_REPORT_BYTES,
|
||||
killOnOutputLimit: true,
|
||||
signal: abort.signal,
|
||||
onChildTerminated: () => {
|
||||
@@ -161,6 +168,8 @@ export function captureNativeHang(options: {
|
||||
release()
|
||||
}
|
||||
})
|
||||
acquired()
|
||||
const result = await pending
|
||||
publish({
|
||||
...baseline,
|
||||
...reduceNativeHangSample(result.stdout),
|
||||
@@ -171,6 +180,14 @@ export function captureNativeHang(options: {
|
||||
cleanup: terminated ? 'terminated' : 'unverified',
|
||||
...(result.code !== null ? { exitCode: result.code } : {})
|
||||
})
|
||||
if (
|
||||
result.code === 0 &&
|
||||
!result.timedOut &&
|
||||
!result.outputTruncated &&
|
||||
!abort.signal.aborted
|
||||
) {
|
||||
options.completeReport?.(result.stdout)
|
||||
}
|
||||
} catch {
|
||||
publish({
|
||||
...baseline,
|
||||
@@ -178,11 +195,13 @@ export function captureNativeHang(options: {
|
||||
completedAt: Date.now(),
|
||||
cleanup: terminated ? 'terminated' : 'unverified'
|
||||
})
|
||||
} finally {
|
||||
acquired()
|
||||
}
|
||||
if (!terminated) {
|
||||
await termination
|
||||
publish({ ...latest, cleanup: 'terminated' })
|
||||
}
|
||||
})()
|
||||
return { stop: () => abort.abort(), done }
|
||||
return { stop: () => abort.abort(), started, done }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { basename } from 'node:path/posix'
|
||||
import { sanitizeCrashReportString } from '../../shared/crash-report-redaction'
|
||||
import { redactString } from '../observability/redactor'
|
||||
|
||||
export const MAX_NATIVE_HANG_REPORT_BYTES = 2 * 1024 * 1024
|
||||
export const MAX_NATIVE_HANG_ARTIFACT_BYTES = 3 * 1024 * 1024
|
||||
|
||||
export type NativeHangProcessMetadata = {
|
||||
appVersion: string
|
||||
electronVersion: string
|
||||
chromeVersion: string
|
||||
arch: string
|
||||
osRelease: string
|
||||
osVersion: string
|
||||
}
|
||||
|
||||
export type NativeHangArtifact = {
|
||||
version: 1
|
||||
captureId: string
|
||||
detectedAt: number
|
||||
capturedAt: number
|
||||
pid: number
|
||||
showMenuBarIcon: boolean | null
|
||||
process: NativeHangProcessMetadata
|
||||
report: string
|
||||
}
|
||||
|
||||
export function sanitizeNativeHangReport(text: string): string | null {
|
||||
if (
|
||||
!text.endsWith('\n') ||
|
||||
!/^OS Version:\s+.+\(.+\)/m.test(text) ||
|
||||
!/^Call graph:/m.test(text) ||
|
||||
!/^\s+\d+ Thread_\S+/m.test(text)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const lines = text.split('\n')
|
||||
const images = lines.indexOf('Binary Images:')
|
||||
if (images === -1) {
|
||||
return null
|
||||
}
|
||||
let imageCount = 0
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index]!
|
||||
if (index > images && line.trim()) {
|
||||
const image =
|
||||
/^(\s*0x[\da-f]+\s+-\s+0x[\da-f]+\s+.+\s+<[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}>)\s+.+$/i.exec(
|
||||
line
|
||||
)
|
||||
if (!image) {
|
||||
return null
|
||||
}
|
||||
lines[index] = `${image[1]} [redacted-path]`
|
||||
imageCount++
|
||||
} else if (/^(?:Path|Analysis Tool):/.test(line)) {
|
||||
lines[index] = `${line.slice(0, line.indexOf(':'))}: [redacted-path]`
|
||||
} else {
|
||||
lines[index] = line.replace(
|
||||
/\(in (\/.+)\)(?=\s+(?:\+\s+\d+\s+)?\[0x[\da-f]+\])/gi,
|
||||
(_match, location: string) => `(in ${basename(location)})`
|
||||
)
|
||||
if (lines[index]!.includes('(in /')) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!imageCount) {
|
||||
return null
|
||||
}
|
||||
// Artifact admission bounds the whole value; scalar truncation would destroy image identities.
|
||||
return redactString(sanitizeCrashReportString(lines.join('\n'), Number.MAX_SAFE_INTEGER))
|
||||
}
|
||||
|
||||
export function parseNativeHangArtifact(value: unknown): NativeHangArtifact | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('version' in value) ||
|
||||
value.version !== 1 ||
|
||||
!('captureId' in value) ||
|
||||
typeof value.captureId !== 'string' ||
|
||||
!('detectedAt' in value) ||
|
||||
typeof value.detectedAt !== 'number' ||
|
||||
!Number.isSafeInteger(value.detectedAt) ||
|
||||
!('capturedAt' in value) ||
|
||||
typeof value.capturedAt !== 'number' ||
|
||||
!Number.isSafeInteger(value.capturedAt) ||
|
||||
!('pid' in value) ||
|
||||
typeof value.pid !== 'number' ||
|
||||
!Number.isSafeInteger(value.pid) ||
|
||||
value.pid <= 0 ||
|
||||
!('showMenuBarIcon' in value) ||
|
||||
(typeof value.showMenuBarIcon !== 'boolean' && value.showMenuBarIcon !== null) ||
|
||||
!('report' in value) ||
|
||||
typeof value.report !== 'string' ||
|
||||
!('process' in value) ||
|
||||
!value.process ||
|
||||
typeof value.process !== 'object'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const metadata = value.process
|
||||
if (
|
||||
!('appVersion' in metadata) ||
|
||||
typeof metadata.appVersion !== 'string' ||
|
||||
!('electronVersion' in metadata) ||
|
||||
typeof metadata.electronVersion !== 'string' ||
|
||||
!('chromeVersion' in metadata) ||
|
||||
typeof metadata.chromeVersion !== 'string' ||
|
||||
!('arch' in metadata) ||
|
||||
typeof metadata.arch !== 'string' ||
|
||||
!('osRelease' in metadata) ||
|
||||
typeof metadata.osRelease !== 'string' ||
|
||||
!('osVersion' in metadata) ||
|
||||
typeof metadata.osVersion !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const report = sanitizeNativeHangReport(value.report)
|
||||
if (!report) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
captureId: value.captureId,
|
||||
detectedAt: value.detectedAt,
|
||||
capturedAt: value.capturedAt,
|
||||
pid: value.pid,
|
||||
showMenuBarIcon: value.showMenuBarIcon,
|
||||
process: {
|
||||
appVersion: sanitizeCrashReportString(metadata.appVersion),
|
||||
electronVersion: sanitizeCrashReportString(metadata.electronVersion),
|
||||
chromeVersion: sanitizeCrashReportString(metadata.chromeVersion),
|
||||
arch: sanitizeCrashReportString(metadata.arch),
|
||||
osRelease: sanitizeCrashReportString(metadata.osRelease),
|
||||
osVersion: sanitizeCrashReportString(metadata.osVersion)
|
||||
},
|
||||
report
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CrashReportStore } from '../crash-reporting/reports/crash-report-store'
|
||||
import { readPreviousNativeHangCapture } from './native-hang-capture-store'
|
||||
|
||||
export function createPreviousNativeHangReport(options: {
|
||||
store: CrashReportStore
|
||||
directory: string
|
||||
processStartedAt: number
|
||||
}): { ensureImported: () => Promise<void> } {
|
||||
let importing: Promise<void> | undefined
|
||||
const prepare = async (): Promise<void> => {
|
||||
const capture = await readPreviousNativeHangCapture(options.directory, options.processStartedAt)
|
||||
if (!capture) {
|
||||
return
|
||||
}
|
||||
const artifact = capture.artifact
|
||||
await options.store.importHangReport({
|
||||
source: 'child',
|
||||
processType: 'main',
|
||||
reason: 'main-thread-hang',
|
||||
exitCode: null,
|
||||
appVersion: artifact.process.appVersion,
|
||||
electronVersion: artifact.process.electronVersion,
|
||||
chromeVersion: artifact.process.chromeVersion,
|
||||
platform: 'darwin',
|
||||
arch: artifact.process.arch,
|
||||
osRelease: artifact.process.osRelease,
|
||||
details: {
|
||||
captureId: artifact.captureId,
|
||||
detectedAt: new Date(artifact.detectedAt).toISOString(),
|
||||
capturedAt: new Date(artifact.capturedAt).toISOString(),
|
||||
targetPid: artifact.pid,
|
||||
osVersion: artifact.process.osVersion,
|
||||
showMenuBarIcon: artifact.showMenuBarIcon
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
ensureImported: () =>
|
||||
(importing ??= prepare()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
importing = undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { EventProps } from '../../shared/telemetry-events'
|
||||
import type { HangDetectionMarker } from './hang-detection-marker'
|
||||
import {
|
||||
listNativeHangCaptures,
|
||||
nativeHangCaptureDirectory,
|
||||
readNativeHangCapture
|
||||
} from './native-hang-capture-store'
|
||||
import { reduceNativeHangSample } from './native-hang-capture'
|
||||
|
||||
export type NativeHangTelemetry = Pick<
|
||||
EventProps<'main_thread_hang_detected'>,
|
||||
'native_top_frames' | 'macos_build' | 'menu_bar_icon'
|
||||
>
|
||||
|
||||
export async function readPreviousNativeHangTelemetry(
|
||||
userDataPath: string,
|
||||
marker: HangDetectionMarker
|
||||
): Promise<NativeHangTelemetry> {
|
||||
const directory = nativeHangCaptureDirectory(userDataPath)
|
||||
for (const file of await listNativeHangCaptures(directory)) {
|
||||
if (file.detectedAt !== marker.detectedAt) {
|
||||
continue
|
||||
}
|
||||
const capture = await readNativeHangCapture(directory, file.captureId)
|
||||
if (!capture || capture.artifact.pid !== marker.parentPid) {
|
||||
continue
|
||||
}
|
||||
const artifact = capture.artifact
|
||||
const macosBuild = /^OS Version:\s*(macOS \d+(?:\.\d+){0,3} \([A-Za-z0-9]+\))\s*$/m.exec(
|
||||
artifact.report
|
||||
)?.[1]
|
||||
return {
|
||||
native_top_frames: reduceNativeHangSample(artifact.report).stacks.flatMap((stack) =>
|
||||
stack.frames.map((frame) => `${frame.name} [${frame.module}]`)
|
||||
),
|
||||
...(macosBuild && macosBuild.length <= 64 ? { macos_build: macosBuild } : {}),
|
||||
menu_bar_icon: artifact.showMenuBarIcon
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createNativeHangCaptureId, NativeHangCaptureStore } from './native-hang-capture-store'
|
||||
import type { NativeHangProcessMetadata } from './native-hang-report'
|
||||
import type { HangObservation } from './hang-observations'
|
||||
|
||||
type CaptureOwner = {
|
||||
id: string
|
||||
reason?: 'recovery' | 'shutdown'
|
||||
stop?: (reason: 'recovery' | 'shutdown') => void
|
||||
}
|
||||
|
||||
export function createWatchdogHangCapture(options: {
|
||||
parentPid: number
|
||||
artifactDirectory?: string
|
||||
processMetadata?: NativeHangProcessMetadata
|
||||
publish: (id: string, change: Partial<HangObservation>) => void
|
||||
drain: (token: string) => Promise<void>
|
||||
onIdle: () => void
|
||||
}): {
|
||||
capture: (record: HangObservation, detectedAt: number, showMenuBarIcon: boolean | null) => void
|
||||
recover: (id: string) => void
|
||||
shutdown: () => void
|
||||
} {
|
||||
let stopped = false
|
||||
let jsOwner: CaptureOwner | null = null
|
||||
let nativeOwner: CaptureOwner | null = null
|
||||
const store = options.artifactDirectory
|
||||
? new NativeHangCaptureStore(options.artifactDirectory)
|
||||
: null
|
||||
const idle = (): void => {
|
||||
if (stopped && !jsOwner && !nativeOwner) {
|
||||
options.onIdle()
|
||||
}
|
||||
}
|
||||
const capture = (
|
||||
record: HangObservation,
|
||||
detectedAt: number,
|
||||
showMenuBarIcon: boolean | null
|
||||
): void => {
|
||||
if (stopped || record.kind !== 'heartbeat-stall') {
|
||||
return
|
||||
}
|
||||
let nativeStarted: Promise<void> = Promise.resolve()
|
||||
if (process.platform === 'darwin' && !nativeOwner) {
|
||||
const owner: CaptureOwner = { id: record.id }
|
||||
nativeOwner = owner
|
||||
const captureId = createNativeHangCaptureId(record.detectedAt)
|
||||
const metadata = options.processMetadata
|
||||
nativeStarted = import('./native-hang-capture')
|
||||
.then((native) => {
|
||||
const child = native.captureNativeHang({
|
||||
pid: options.parentPid,
|
||||
publish: (native) => options.publish(record.id, { native }),
|
||||
completeReport: (report) => {
|
||||
if (store && metadata) {
|
||||
void store.save({
|
||||
version: 1,
|
||||
captureId,
|
||||
detectedAt: record.detectedAt,
|
||||
capturedAt: Date.now(),
|
||||
pid: options.parentPid,
|
||||
showMenuBarIcon,
|
||||
process: metadata,
|
||||
report
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
owner.stop = () => child.stop()
|
||||
if (owner.reason === 'shutdown') {
|
||||
child.stop()
|
||||
}
|
||||
void child.done.finally(() => {
|
||||
if (nativeOwner === owner) {
|
||||
nativeOwner = null
|
||||
}
|
||||
idle()
|
||||
})
|
||||
return child.started
|
||||
})
|
||||
.catch(() => {
|
||||
if (nativeOwner === owner) {
|
||||
nativeOwner = null
|
||||
}
|
||||
idle()
|
||||
})
|
||||
}
|
||||
if (!jsOwner) {
|
||||
const owner: CaptureOwner = { id: record.id }
|
||||
jsOwner = owner
|
||||
void (async () => {
|
||||
await nativeStarted
|
||||
const [inspector, js] = await Promise.all([
|
||||
import('node:inspector'),
|
||||
import('./main-js-hang-capture')
|
||||
])
|
||||
const mainJs = js.captureMainJsHang({
|
||||
session: new inspector.Session(),
|
||||
detectedAt,
|
||||
now: () => performance.now(),
|
||||
publish: (mainJs) => options.publish(record.id, { mainJs }),
|
||||
drain: () => options.drain(record.id)
|
||||
})
|
||||
owner.stop = mainJs.stop
|
||||
if (owner.reason) {
|
||||
mainJs.stop(owner.reason)
|
||||
}
|
||||
await mainJs.done
|
||||
})()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (jsOwner === owner) {
|
||||
jsOwner = null
|
||||
}
|
||||
idle()
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
capture,
|
||||
recover: (id) => {
|
||||
if (jsOwner?.id === id) {
|
||||
jsOwner.reason ??= 'recovery'
|
||||
jsOwner.stop?.('recovery')
|
||||
}
|
||||
},
|
||||
shutdown: () => {
|
||||
stopped = true
|
||||
for (const owner of [jsOwner, nativeOwner]) {
|
||||
if (owner) {
|
||||
owner.reason ??= 'shutdown'
|
||||
owner.stop?.('shutdown')
|
||||
}
|
||||
}
|
||||
idle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,12 @@ export async function getLatestPendingReport(
|
||||
): Promise<Awaited<ReturnType<CrashReportStore['getLatestPending']>>> {
|
||||
const reports = await store.listRecent()
|
||||
return (
|
||||
reports.find((report) => report.status === 'pending' && !submittedReportIds.has(report.id)) ??
|
||||
null
|
||||
reports.find(
|
||||
(report) =>
|
||||
report.status === 'pending' &&
|
||||
report.reason !== 'main-thread-hang' &&
|
||||
!submittedReportIds.has(report.id)
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user