mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
Merge branch 'main' into brennanb2025/relay-completion-unverifiable-r1
This commit is contained in:
@@ -173,6 +173,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -46,8 +46,6 @@ on:
|
||||
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
|
||||
- 'src/shared/computer-use-*.ts'
|
||||
- 'tests/e2e/computer-linux.e2e.ts'
|
||||
- 'tests/e2e/computer-mac.e2e.ts'
|
||||
- 'tests/e2e/computer-mac-safari.e2e.ts'
|
||||
- 'tests/e2e/computer-windows.e2e.ts'
|
||||
- 'tests/e2e/computer-windows-store.e2e.ts'
|
||||
- 'tests/e2e/helpers/computer-cli-driver.ts'
|
||||
@@ -137,8 +135,9 @@ jobs:
|
||||
- name: Windows daemon workspace-close repro
|
||||
if: runner.os == 'Windows'
|
||||
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
|
||||
# Hosted macOS runners cannot receive persistent Accessibility/Screen Recording
|
||||
# grants. Keep the real native build/owner-loss checks on every trigger instead.
|
||||
mac-native-owner-smoke:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-15
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -159,28 +158,6 @@ jobs:
|
||||
- name: Swift tests and signed universal helper verification
|
||||
run: pnpm verify:computer-native
|
||||
|
||||
mac:
|
||||
# macOS Accessibility and Screen Recording require user-granted TCC entries.
|
||||
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: macos-15
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:computer-macos
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
|
||||
|
||||
linux:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -189,20 +166,12 @@ jobs:
|
||||
ACCESSIBILITY_ENABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
persist-credentials: false
|
||||
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
|
||||
# Why: keep scheduled Linux e2e on the same native install path as PR
|
||||
# smoke and pr.yml's verify job.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
@@ -215,13 +184,11 @@ jobs:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -147,6 +147,7 @@ jobs:
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -193,6 +193,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
+63
-33
@@ -81,26 +81,37 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/10'
|
||||
shard_name: 1-of-10
|
||||
- shard: '2/10'
|
||||
shard_name: 2-of-10
|
||||
- shard: '3/10'
|
||||
shard_name: 3-of-10
|
||||
- shard: '4/10'
|
||||
shard_name: 4-of-10
|
||||
- shard: '5/10'
|
||||
shard_name: 5-of-10
|
||||
- shard: '6/10'
|
||||
shard_name: 6-of-10
|
||||
- shard: '7/10'
|
||||
shard_name: 7-of-10
|
||||
- shard: '8/10'
|
||||
shard_name: 8-of-10
|
||||
- shard: '9/10'
|
||||
shard_name: 9-of-10
|
||||
- shard: '10/10'
|
||||
shard_name: 10-of-10
|
||||
# Fourteen scheduled runs averaged 24.6 minutes per shard; shards 4
|
||||
# and 9 repeatedly hit the 30-minute cap. A 12-way trial still left
|
||||
# one 30-minute shard, so 14 gives the suite enough failure headroom.
|
||||
- shard: '1/14'
|
||||
shard_name: 1-of-14
|
||||
- shard: '2/14'
|
||||
shard_name: 2-of-14
|
||||
- shard: '3/14'
|
||||
shard_name: 3-of-14
|
||||
- shard: '4/14'
|
||||
shard_name: 4-of-14
|
||||
- shard: '5/14'
|
||||
shard_name: 5-of-14
|
||||
- shard: '6/14'
|
||||
shard_name: 6-of-14
|
||||
- shard: '7/14'
|
||||
shard_name: 7-of-14
|
||||
- shard: '8/14'
|
||||
shard_name: 8-of-14
|
||||
- shard: '9/14'
|
||||
shard_name: 9-of-14
|
||||
- shard: '10/14'
|
||||
shard_name: 10-of-14
|
||||
- shard: '11/14'
|
||||
shard_name: 11-of-14
|
||||
- shard: '12/14'
|
||||
shard_name: 12-of-14
|
||||
- shard: '13/14'
|
||||
shard_name: 13-of-14
|
||||
- shard: '14/14'
|
||||
shard_name: 14-of-14
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -108,18 +119,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: pnpm install used to rebuild native modules here; the composite
|
||||
# action restores them from cache and only compiles on a miss. The
|
||||
# toolchain is still required for that miss path, and for paired Quick
|
||||
# Open coverage which exercises the resource-bounded host search.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
# virtual framebuffer so Chromium can initialize without a real display.
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get install -y xvfb
|
||||
# Native cache misses need the compiler, Electron needs Xvfb, and paired
|
||||
# Quick Open needs ripgrep. Install them in one apt transaction per shard.
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -265,12 +268,31 @@ jobs:
|
||||
- name: Run Docker SSH watcher isolation E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
|
||||
# Why: Playwright empties test-results/ when it starts, so each step here used to
|
||||
# destroy the previous step's traces. Only the last lane's failure was ever
|
||||
# diagnosable from the artifact; set each lane aside before the next one runs.
|
||||
- name: Keep watcher-isolation traces
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d test-results ]; then
|
||||
mkdir -p e2e-traces
|
||||
mv test-results "e2e-traces/watcher-isolation"
|
||||
fi
|
||||
|
||||
# Why always(): this lane gates SSH parking/retention plus startup-exec
|
||||
# readiness across live SSH, headed paired, and headless serve topologies.
|
||||
- name: Run Docker SSH terminal parking + startup readiness E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
- name: Keep terminal-parking traces
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d test-results ]; then
|
||||
mkdir -p e2e-traces
|
||||
mv test-results "e2e-traces/terminal-parking"
|
||||
fi
|
||||
|
||||
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
|
||||
# spec below skipped itself while the shard still reported green. Running them on this one
|
||||
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
|
||||
@@ -279,11 +301,19 @@ jobs:
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
|
||||
|
||||
- name: Keep remaining-ssh-docker traces
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d test-results ]; then
|
||||
mkdir -p e2e-traces
|
||||
mv test-results "e2e-traces/remaining-ssh-docker"
|
||||
fi
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-ssh-docker-watcher-isolation
|
||||
path: test-results/
|
||||
path: e2e-traces/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
# Why: Linux golden E2E uses the same native install path as PR/release CI,
|
||||
|
||||
@@ -137,6 +137,7 @@ jobs:
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
# Why: mirrors pr.yml so native module rebuilds do not use pnpm's
|
||||
|
||||
@@ -46,6 +46,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -66,6 +66,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -53,6 +53,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
# Why: the mobile typecheck imports shared types from ../src/shared, and
|
||||
|
||||
@@ -595,9 +595,13 @@ jobs:
|
||||
with:
|
||||
native-runtime: electron
|
||||
|
||||
# Why --no-file-parallelism: every file here launches a full Electron stack twice, and each
|
||||
# probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other
|
||||
# past those deadlines; serial, every probe owns the runner.
|
||||
- name: Test Linux Electron lifecycle boundary
|
||||
run: >-
|
||||
xvfb-run --auto-servernum pnpm exec vitest run --config config/vitest.config.ts
|
||||
--no-file-parallelism
|
||||
src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts
|
||||
src/main/browser/browser-route-tcp-egress.electron.test.ts
|
||||
src/main/browser/browser-route-webrtc-egress.electron.test.ts
|
||||
|
||||
@@ -866,6 +866,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
# Why: Linux terminal golden E2E uses the same native install path as
|
||||
@@ -1081,6 +1082,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
# Why: keep the non-blocking evidence lane on the same Linux native
|
||||
@@ -1195,6 +1197,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm bundled copy
|
||||
|
||||
@@ -75,6 +75,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
# Why: this scheduled/manual workflow uses the same native install path as
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -87,6 +87,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
# Why: the harness only needs its runtime deps (the Playwright Electron
|
||||
|
||||
@@ -60,6 +60,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -34,6 +34,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -6321,36 +6321,48 @@
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.startup-cwd-missing-dir-recovery",
|
||||
"title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd",
|
||||
"title": "Terminal working-directory recovery stays scoped and provider-safe",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "shared-main-renderer-contract",
|
||||
"surfaces": ["terminal lifecycle", "tab creation", "PTY spawn", "startup cwd persistence"],
|
||||
"layer": "shared-main-renderer-and-posix-shell-contract",
|
||||
"surfaces": [
|
||||
"terminal lifecycle",
|
||||
"tab creation",
|
||||
"PTY spawn",
|
||||
"startup cwd persistence",
|
||||
"long-lived Bash and Zsh OMP launches"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows", "mobile"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the probe by design and lack a live run; Linux/Windows and mobile/API strictness are gaps.",
|
||||
"coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Real Bash and Zsh node-pty tests replace the active cwd inode and exercise the shared POSIX OMP wrapper; generated-file snapshots pin identical local, daemon/SSH, and relay wrappers. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the spawn probe and lack a live run; native PowerShell is unaffected; Linux/Windows and mobile/API strictness remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7239",
|
||||
"https://github.com/stablyai/orca/pull/7750",
|
||||
"https://github.com/stablyai/orca/pull/7678"
|
||||
"https://github.com/stablyai/orca/pull/7678",
|
||||
"https://github.com/stablyai/orca/issues/16457",
|
||||
"https://github.com/stablyai/orca/pull/17128"
|
||||
],
|
||||
"invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics.",
|
||||
"oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path.",
|
||||
"invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics. A long-lived POSIX shell whose cwd inode was deleted and replaced may run an extension-enabled OMP launch from the live path named by its logical PWD only in a subshell; the parent shell cwd, OMP argv, status extension, and exit status remain unchanged, while a genuinely unavailable path fails visibly without invoking OMP. A usable current directory remains authoritative when PWD is unset instead of being remapped to a workspace fallback.",
|
||||
"oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path. In real interactive Bash and Zsh PTYs, unset PWD in a usable nested directory and require OMP to stay there rather than remap to ORCA_WORKTREE_PATH; then delete and recreate the active project path, require OMP to observe the replacement inode with byte-exact extension argv and its nonzero status preserved, require the parent shell to remain on the stale inode, and finally delete the replacement and require an actionable failure before the fake OMP binary runs again.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-wsl-cwd-validation.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.node-pty.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/terminal-startup-cwd.test.ts",
|
||||
"src/main/ipc/pty-spawn-cwd-fallback.test.ts",
|
||||
"src/main/ipc/pty-wsl-cwd-validation.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts"
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts",
|
||||
"src/main/pty/omp-shell-wrapper.node-pty.test.ts",
|
||||
"src/main/pty/omp-shell-wrapper-alias-safety.test.ts",
|
||||
"src/main/shell-wrapper-generated-file-snapshot.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -6392,6 +6404,21 @@
|
||||
"startup cwd fallback metadata prints a generic in-terminal notice",
|
||||
"remote-runtime worktree spawns are not marked with cwdFallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/pty/omp-shell-wrapper.node-pty.test.ts",
|
||||
"assertions": [
|
||||
"real Bash and Zsh PTYs rebind OMP to a recreated cwd inode without changing the parent shell",
|
||||
"an unset PWD with a usable cwd stays unset and does not remap OMP to ORCA_WORKTREE_PATH",
|
||||
"extension argv and a nonzero OMP exit status survive the recovery subshell",
|
||||
"a subsequently missing logical cwd prints an actionable error and does not invoke OMP again"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/shell-wrapper-generated-file-snapshot.test.ts",
|
||||
"assertions": [
|
||||
"local, daemon/SSH, and relay Bash/Zsh files contain the byte-identical shared OMP wrapper"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -6430,11 +6457,20 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 6.5,
|
||||
"summary": "1 test file passed, 341 tests passed; covers local IPC marking, the generic terminal fallback notice, and remote-runtime omission."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.node-pty.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3,
|
||||
"summary": "3 test files passed; real Bash and Zsh PTYs covered unset-PWD authority, stale-inode recovery, parent-shell isolation, extension argv, exit status, missing-path visibility, alias safety, and generated local/daemon/relay parity."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "focused unit and IPC contract tests"
|
||||
"p95Seconds": 40,
|
||||
"scope": "focused unit, IPC, generated-wrapper, and real Bash/Zsh PTY contracts"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
@@ -6442,23 +6478,25 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The main IPC missing-cwd tests fail with the provider's 'Working directory ... does not exist.' error when the fallback is removed and pass with it. Full live Electron reproduction from a production persisted session is not captured."
|
||||
"evidence": "The main IPC missing-cwd tests fail with the provider's 'Working directory ... does not exist.' error when the spawn fallback is removed and pass with it. Before the OMP recovery, the stale-inode reproduction makes the child process fail to resolve its cwd; the candidate rebinds to the recreated path in real Bash and Zsh PTYs. Full live Electron reproduction from a production persisted session is not captured."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The runtime change adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs; no polling, provider listing, hidden-pane work, startup awaits, subprocesses, or render-loop work was added."
|
||||
"evidence": "The spawn recovery adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs. Each extension-enabled OMP launch adds a constant number of shell filesystem predicates; only a detected stale/unusable cwd creates one short recovery subshell. No polling, retry, provider listing, hidden-pane work, startup awaits, extra OMP subprocesses, or render-loop work was added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Attach CI evidence for all declared test files.",
|
||||
"Add a live Electron regression that opens a local terminal whose persisted startupCwd was deleted and proves visible shell input/output at the workspace root.",
|
||||
"Add WSL/mobile/API provider-contract coverage or explicitly narrow their risk scope."
|
||||
"Add a live stale-inode OMP run on Linux or WSL and through an SSH/relay-generated wrapper.",
|
||||
"Add WSL/mobile/API spawn provider-contract coverage or explicitly narrow their risk scope."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live Electron fixture seeds a persisted tab whose startupCwd directory was deleted.",
|
||||
"Daemon coverage is via the shared pre-provider main cwd decision, not a live daemon provider spawn.",
|
||||
"WSL UNC paths bypass the probe by design and have no live existence-recovery run; Linux, Windows, and mobile/API strictness are not directly exercised."
|
||||
"The stale-inode OMP oracle is local macOS Bash/Zsh plus shared generated-file parity; it does not execute through live SSH, relay, Linux, or WSL providers.",
|
||||
"WSL UNC paths bypass the spawn probe by design and have no live existence-recovery run; Linux, Windows, and mobile/API spawn strictness are not directly exercised."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, or if a reattach/remote/API caller can engage the fallback."
|
||||
"demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, if a reattach/remote/API caller can engage the spawn fallback, or if OMP recovery remaps a usable cwd with unset PWD, changes argv/status/parent cwd, or invokes OMP when the logical path remains unavailable."
|
||||
},
|
||||
{
|
||||
"id": "agent-status.pi-hook-liveness",
|
||||
|
||||
@@ -37,4 +37,16 @@ describe('client-hosted browser package coverage', () => {
|
||||
expect(windowsStep.run).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
// Why pinned: each of those files launches a full Electron stack twice under its own in-process
|
||||
// deadline. Letting the runner interleave four of them starved the probes past those deadlines,
|
||||
// which is the only way this step has ever failed.
|
||||
it('gives each Linux Electron probe the runner to itself', () => {
|
||||
const parsedWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const linuxStep = parsedWorkflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Test Linux Electron lifecycle boundary'
|
||||
)
|
||||
|
||||
expect(linuxStep.run).toContain('--no-file-parallelism')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(config).toContain('fileParallelism: false')
|
||||
})
|
||||
|
||||
it('guards e2e source against fragile fixed waits and stale element indexes', () => {
|
||||
it('guards e2e source against fragile waits and Windows Calculator drift', () => {
|
||||
const driver = readFileSync(join(projectDir, 'tests/e2e/helpers/computer-driver.ts'), 'utf8')
|
||||
const cliDriver = readFileSync(
|
||||
join(projectDir, 'tests/e2e/helpers/computer-cli-driver.ts'),
|
||||
@@ -31,11 +31,14 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(cliDriver).toContain('Could not read Orca runtime metadata')
|
||||
expect(cliDriver).toContain("'serve', '--no-pairing', '--json'")
|
||||
|
||||
expect(windowsStoreE2e).toMatch(
|
||||
/for \(const buttonName of \['One', 'Plus', 'Two', 'Equals'\]\) \{[\s\S]*findRoleIndex\(state\.result\.snapshot\.treeText, `button \$\{buttonName\}`\)[\s\S]*state = parseJsonOutput/
|
||||
expect(windowsStoreE2e).toContain("app.bundleId === 'ApplicationFrameHost'")
|
||||
expect(windowsStoreE2e).toContain("app.bundleId === 'win32calc'")
|
||||
expect(windowsStoreE2e).toContain('buttonIndex >= 0')
|
||||
expect(windowsStoreE2e).toContain('pane(?:\\s|$)/m')
|
||||
expect(windowsStoreE2e).toContain('String(clickIndex)')
|
||||
expect(windowsStoreE2e).not.toContain(
|
||||
"for (const buttonName of ['One', 'Plus', 'Two', 'Equals'])"
|
||||
)
|
||||
expect(windowsStoreE2e).not.toMatch(/const one = findRoleIndex/)
|
||||
expect(windowsStoreE2e).not.toMatch(/for \(const index of \[one, plus, two, equals\]\)/)
|
||||
})
|
||||
|
||||
it('triggers on computer-use shared contracts, scripts, and agent skill changes', () => {
|
||||
@@ -118,7 +121,7 @@ describe('computer-use e2e workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('builds and tests the macOS helper on pull requests without TCC e2e', () => {
|
||||
it('builds and tests the macOS helper on every trigger without hosted TCC e2e', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
@@ -129,7 +132,7 @@ describe('computer-use e2e workflow', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job.if).toBeUndefined()
|
||||
expect(job['runs-on']).toBe('macos-15')
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(install.with['native-runtime']).toBe('electron')
|
||||
@@ -142,6 +145,7 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
expect(runs).toContain('pnpm verify:computer-native')
|
||||
expect(runs.join('\n')).not.toContain('test:e2e:computer')
|
||||
expect(workflow.jobs.mac).toBeUndefined()
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs',
|
||||
@@ -154,6 +158,29 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the cached Electron dependency path for scheduled Linux and Windows e2e', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
for (const jobName of ['linux', 'windows']) {
|
||||
const job = workflow.jobs[jobName]
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(checkout.with['persist-credentials'], jobName).toBe(false)
|
||||
expect(install.with['native-runtime'], jobName).toBe('electron')
|
||||
expect(
|
||||
job.steps.some((step) => step.uses === 'pnpm/action-setup@v6'),
|
||||
jobName
|
||||
).toBe(false)
|
||||
expect(
|
||||
job.steps.some((step) => step.run === 'pnpm install --frozen-lockfile'),
|
||||
jobName
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => {
|
||||
const benchmark = readFileSync(
|
||||
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'),
|
||||
@@ -244,7 +271,7 @@ describe('computer-use e2e workflow', () => {
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
for (const jobName of ['native-smoke', 'mac', 'linux', 'windows']) {
|
||||
for (const jobName of ['native-smoke', 'linux', 'windows']) {
|
||||
const runs = workflow.jobs[jobName].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
@@ -274,7 +301,6 @@ describe('computer-use e2e workflow', () => {
|
||||
.filter((run) => typeof run === 'string')
|
||||
const allRuns = [
|
||||
...nativeSmokeRuns,
|
||||
...workflow.jobs.mac.steps.map((step) => step.run).filter((run) => typeof run === 'string'),
|
||||
...workflow.jobs.linux.steps.map((step) => step.run).filter((run) => typeof run === 'string'),
|
||||
...workflow.jobs.windows.steps
|
||||
.map((step) => step.run)
|
||||
@@ -286,30 +312,25 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter')
|
||||
})
|
||||
|
||||
it('runs macOS and Linux computer-use e2e files in scheduled jobs', () => {
|
||||
it('runs Linux e2e on schedule without advertising hosted macOS TCC coverage', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const triggerPaths = workflow.on.pull_request.paths
|
||||
const macRuns = workflow.jobs.mac.steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const linuxRuns = workflow.jobs.linux.steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'tests/e2e/computer-mac.e2e.ts',
|
||||
'tests/e2e/computer-mac-safari.e2e.ts',
|
||||
'tests/e2e/computer-linux.e2e.ts',
|
||||
'tests/e2e/helpers/computer-cli-driver.ts',
|
||||
'tests/e2e/helpers/computer-driver.ts'
|
||||
])
|
||||
)
|
||||
expect(macRuns).toContain(
|
||||
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts'
|
||||
)
|
||||
expect(triggerPaths).not.toContain('tests/e2e/computer-mac.e2e.ts')
|
||||
expect(triggerPaths).not.toContain('tests/e2e/computer-mac-safari.e2e.ts')
|
||||
expect(workflow.jobs.mac).toBeUndefined()
|
||||
expect(linuxRuns).toContain(
|
||||
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
|
||||
)
|
||||
|
||||
@@ -5032,5 +5032,50 @@
|
||||
},
|
||||
"auto.components.right.sidebar.AiVaultPanel.noAgentsSelected": {
|
||||
"ko": "선택된 에이전트가 없습니다"
|
||||
},
|
||||
"dashboardPopout.title": {
|
||||
"ko": "에이전트"
|
||||
},
|
||||
"dashboardPopout.total": {
|
||||
"ko": "총 {{count}}개"
|
||||
},
|
||||
"dashboardPopout.close": {
|
||||
"ko": "대시보드 닫기"
|
||||
},
|
||||
"dashboardPopout.placeholder.title": {
|
||||
"ko": "에이전트 대시보드"
|
||||
},
|
||||
"dashboardPopout.placeholder.description": {
|
||||
"ko": "모든 에이전트를 한눈에 볼 수 있는 곳입니다. 보드는 곧 제공됩니다."
|
||||
},
|
||||
"dashboardPopout.recoverableError.title": {
|
||||
"ko": "Orca 대시보드에서 오류가 발생했습니다."
|
||||
},
|
||||
"dashboardPopout.recoverableError.description": {
|
||||
"ko": "대시보드 렌더링을 완료하지 못했습니다. 다시 시도해 다시 마운트하거나 다시 열어 보세요."
|
||||
},
|
||||
"dashboardPopout.bucket.attention": {
|
||||
"ko": "확인 필요"
|
||||
},
|
||||
"dashboardPopout.bucket.working": {
|
||||
"ko": "작업 중"
|
||||
},
|
||||
"dashboardPopout.bucket.idle": {
|
||||
"ko": "유휴"
|
||||
},
|
||||
"dashboardPopout.bucket.empty": {
|
||||
"ko": "없음"
|
||||
},
|
||||
"dashboardPopout.card.you": {
|
||||
"ko": "나"
|
||||
},
|
||||
"dashboardPopout.terminal.closed": {
|
||||
"ko": "실시간 터미널이 없습니다 — 이 에이전트의 창이 닫혔습니다."
|
||||
},
|
||||
"dashboardPopout.terminal.focusWorktree": {
|
||||
"ko": "워크트리 열기"
|
||||
},
|
||||
"dashboardPopout.terminal.close": {
|
||||
"ko": "닫기"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,12 @@ describe('PR E2E gate contract', () => {
|
||||
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''")
|
||||
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
|
||||
expect(e2eWorkflow.jobs.e2e.strategy.matrix.include).toEqual(
|
||||
Array.from({ length: 14 }, (_, index) => ({
|
||||
shard: `${index + 1}/14`,
|
||||
shard_name: `${index + 1}-of-14`
|
||||
}))
|
||||
)
|
||||
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
|
||||
(step) => step.name === 'Run changed E2E specs'
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ const shellContractFiles = [
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
|
||||
'src/main/shell-startup-feature-channel.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
|
||||
@@ -22,7 +23,6 @@ const shellContractFiles = [
|
||||
]
|
||||
const patchedNodePtyContractFiles = [
|
||||
'src/main/daemon/node-pty-fd-leak.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
|
||||
'src/shared/fish-query-reply-child-stdin.node-pty.test.ts'
|
||||
]
|
||||
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
|
||||
@@ -232,6 +232,23 @@ describe('PR workflow parallelism', () => {
|
||||
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
|
||||
})
|
||||
|
||||
it('pins every direct pnpm setup to the repository package-manager version', () => {
|
||||
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
|
||||
const directSetups = globSync('.github/workflows/*.yml').flatMap((workflowPath) => {
|
||||
const parsed = parse(readFileSync(workflowPath, 'utf8'))
|
||||
return Object.values(parsed.jobs ?? {}).flatMap((job) =>
|
||||
(job.steps ?? [])
|
||||
.filter((step) => step.uses === 'pnpm/action-setup@v6')
|
||||
.map((step) => ({ workflowPath, step }))
|
||||
)
|
||||
})
|
||||
|
||||
expect(directSetups.length).toBeGreaterThan(0)
|
||||
for (const { workflowPath, step } of directSetups) {
|
||||
expect(step.with?.version, workflowPath).toBe(packageManagerVersion)
|
||||
}
|
||||
})
|
||||
|
||||
it('restores Electron downloads before preparing the package runtime', () => {
|
||||
const steps = workflow.jobs.package.steps
|
||||
const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
setupAgentStartupPolicy: wait-for-setup
|
||||
scripts:
|
||||
setup: |
|
||||
node config/scripts/run-internal-dev-setup.mjs
|
||||
|
||||
@@ -44,9 +44,17 @@ __orca_omp_should_skip_extension() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" ]]; then
|
||||
[[ -d "${PWD}" && "${PWD}" -ef . ]]
|
||||
else
|
||||
builtin pwd -P >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -58,6 +66,27 @@ __orca_omp() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 ]] && ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -82,9 +82,17 @@ __orca_deferred_init() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" ]]; then
|
||||
[[ -d "${PWD}" && "${PWD}" -ef . ]]
|
||||
else
|
||||
builtin pwd -P >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -96,6 +104,27 @@ __orca_deferred_init() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 ]] && ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -47,9 +47,17 @@ __orca_omp_should_skip_extension() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" ]]; then
|
||||
[[ -d "${PWD}" && "${PWD}" -ef . ]]
|
||||
else
|
||||
builtin pwd -P >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -61,6 +69,27 @@ __orca_omp() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 ]] && ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -82,9 +82,17 @@ __orca_deferred_init() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" ]]; then
|
||||
[[ -d "${PWD}" && "${PWD}" -ef . ]]
|
||||
else
|
||||
builtin pwd -P >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -96,6 +104,27 @@ __orca_deferred_init() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 ]] && ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -36,9 +36,17 @@ __orca_omp_should_skip_extension() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" ]]; then
|
||||
[[ -d "${PWD}" && "${PWD}" -ef . ]]
|
||||
else
|
||||
builtin pwd -P >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -50,6 +58,27 @@ __orca_omp() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 ]] && ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -56,9 +56,17 @@ __orca_deferred_init() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "${PWD:-}" ]]; then
|
||||
[[ -d "${PWD}" && "${PWD}" -ef . ]]
|
||||
else
|
||||
builtin pwd -P >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -70,6 +78,27 @@ __orca_deferred_init() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 ]] && ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
@@ -276,11 +277,12 @@ async function runFixture(): Promise<FixtureResult> {
|
||||
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 60_000 })
|
||||
const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
|
||||
@@ -20,12 +21,13 @@ export async function runBrowserRouteEgressElectron(
|
||||
`--user-data-dir=${join(root, 'profile')}`,
|
||||
...extraElectronArgs
|
||||
]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const child = spawn(executable, args, {
|
||||
detached: true,
|
||||
env,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
|
||||
@@ -20,12 +21,13 @@ export async function runPersistedWorkerElectron(
|
||||
const resultPath = join(root, 'result.json')
|
||||
rmSync(resultPath, { force: true })
|
||||
const electronArgs = [mainPath, configPath, mode, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const child = spawn(executable, args, {
|
||||
detached: true,
|
||||
env,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
@@ -138,11 +139,12 @@ function runProbe(protectedGuest: boolean): ProbeResult {
|
||||
writeFileSync(mainPath, probeMain(resultPath, protectedGuest))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const { executable, args } = resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: process.platform,
|
||||
display: env.DISPLAY
|
||||
})
|
||||
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 30_000 })
|
||||
const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
|
||||
|
||||
const electronBinary = '/tmp/electron'
|
||||
const electronArgs = ['/tmp/main.cjs', '--user-data-dir=/tmp/profile']
|
||||
|
||||
describe('electron probe launch resolution', () => {
|
||||
it('reuses an inherited X display instead of nesting another xvfb-run server', () => {
|
||||
expect(
|
||||
resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: 'linux',
|
||||
display: ':99'
|
||||
})
|
||||
).toEqual({
|
||||
executable: electronBinary,
|
||||
args: ['/tmp/main.cjs', '--user-data-dir=/tmp/profile', '--no-sandbox']
|
||||
})
|
||||
})
|
||||
|
||||
it('owns a display when Linux hands the probe none', () => {
|
||||
expect(
|
||||
resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: 'linux',
|
||||
display: undefined
|
||||
})
|
||||
).toEqual({
|
||||
executable: 'xvfb-run',
|
||||
args: [
|
||||
'--auto-servernum',
|
||||
electronBinary,
|
||||
'/tmp/main.cjs',
|
||||
'--user-data-dir=/tmp/profile',
|
||||
'--no-sandbox'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an empty DISPLAY as no display', () => {
|
||||
expect(
|
||||
resolveElectronProbeLaunch({ electronBinary, electronArgs, platform: 'linux', display: '' })
|
||||
.executable
|
||||
).toBe('xvfb-run')
|
||||
})
|
||||
|
||||
it('leaves non-Linux launches on the plain Electron binary', () => {
|
||||
expect(
|
||||
resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform: 'darwin',
|
||||
display: undefined
|
||||
})
|
||||
).toEqual({ executable: electronBinary, args: electronArgs })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
export type ElectronProbeLaunch = {
|
||||
executable: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
export type ElectronProbeLaunchInput = {
|
||||
electronBinary: string
|
||||
electronArgs: readonly string[]
|
||||
platform: NodeJS.Platform
|
||||
display: string | undefined
|
||||
}
|
||||
|
||||
// Why: these probes each start a full Electron stack, and CI already runs the whole vitest
|
||||
// invocation under one `xvfb-run`. Nesting a private `--auto-servernum` server per probe adds an
|
||||
// X server per concurrent file and races the sibling probes for a free display number, so reuse
|
||||
// the display we were handed and only own one when there is none.
|
||||
export function resolveElectronProbeLaunch({
|
||||
electronBinary,
|
||||
electronArgs,
|
||||
platform,
|
||||
display
|
||||
}: ElectronProbeLaunchInput): ElectronProbeLaunch {
|
||||
if (platform !== 'linux') {
|
||||
return { executable: electronBinary, args: [...electronArgs] }
|
||||
}
|
||||
const linuxArgs = [...electronArgs, '--no-sandbox']
|
||||
if (display) {
|
||||
return { executable: electronBinary, args: linuxArgs }
|
||||
}
|
||||
return { executable: 'xvfb-run', args: ['--auto-servernum', electronBinary, ...linuxArgs] }
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildGpuCrashDiagnostics, GpuCrashDiagnosticsRecorder } from './gpu-crash-diagnostics'
|
||||
|
||||
const FEATURE_STATUS = {
|
||||
gpu_compositing: 'enabled',
|
||||
rasterization: 'enabled',
|
||||
webgl: 'enabled',
|
||||
webgl2: 'enabled',
|
||||
video_decode: 'enabled'
|
||||
}
|
||||
|
||||
const BASIC_INFO = {
|
||||
gpuDevice: [
|
||||
{
|
||||
active: false,
|
||||
vendorId: 0x8086,
|
||||
deviceId: 0x9a49,
|
||||
vendorString: 'Intel',
|
||||
deviceString: 'Integrated GPU'
|
||||
},
|
||||
{
|
||||
active: true,
|
||||
vendorId: 0x10de,
|
||||
deviceId: 0x2684,
|
||||
vendorString: 'NVIDIA',
|
||||
deviceString: 'Discrete GPU',
|
||||
driverVendor: 'NVIDIA',
|
||||
driverVersion: '32.0.15.6094'
|
||||
}
|
||||
],
|
||||
auxAttributes: {
|
||||
glVendor: 'Google Inc.',
|
||||
glRenderer: 'ANGLE (NVIDIA, D3D11)',
|
||||
glVersion: 'OpenGL ES 3.0'
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolvePromise: ((value: T) => void) | undefined
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve: (value) => resolvePromise?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildGpuCrashDiagnostics', () => {
|
||||
it('keeps only the active GPU identity, driver, rendering backend, and feature status', () => {
|
||||
expect(
|
||||
buildGpuCrashDiagnostics({ info: BASIC_INFO, level: 'complete' }, FEATURE_STATUS)
|
||||
).toEqual({
|
||||
gpuInfoLevel: 'complete',
|
||||
gpuDeviceCount: 2,
|
||||
gpuVendorId: 0x10de,
|
||||
gpuDeviceId: 0x2684,
|
||||
gpuVendor: 'NVIDIA',
|
||||
gpuDevice: 'Discrete GPU',
|
||||
gpuDriverVendor: 'NVIDIA',
|
||||
gpuDriverVersion: '32.0.15.6094',
|
||||
gpuGlVendor: 'Google Inc.',
|
||||
gpuGlRenderer: 'ANGLE (NVIDIA, D3D11)',
|
||||
gpuGlVersion: 'OpenGL ES 3.0',
|
||||
gpuCompositingStatus: 'enabled',
|
||||
gpuRasterizationStatus: 'enabled',
|
||||
gpuWebglStatus: 'enabled',
|
||||
gpuWebgl2Status: 'enabled',
|
||||
gpuVideoDecodeStatus: 'enabled'
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades malformed or unavailable GPU info without copying arbitrary fields', () => {
|
||||
expect(
|
||||
buildGpuCrashDiagnostics(
|
||||
{
|
||||
level: 'basic',
|
||||
info: {
|
||||
gpuDevice: [{ active: true, vendorId: Number.NaN, secret: 'do not copy' }],
|
||||
machineModelName: 'do not copy'
|
||||
}
|
||||
},
|
||||
{ webgl: 'unavailable', unexpected: 'do not copy' }
|
||||
)
|
||||
).toEqual({
|
||||
gpuInfoLevel: 'basic',
|
||||
gpuDeviceCount: 1,
|
||||
gpuWebglStatus: 'unavailable'
|
||||
})
|
||||
expect(buildGpuCrashDiagnostics(null, null)).toEqual({ gpuInfoLevel: 'unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GpuCrashDiagnosticsRecorder', () => {
|
||||
it('warms only complete info and records one breadcrumb across a crash burst', async () => {
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn(async (level: 'basic' | 'complete') => ({
|
||||
...BASIC_INFO,
|
||||
gpuDevice: BASIC_INFO.gpuDevice.map((device) => ({
|
||||
...device,
|
||||
...(level === 'complete' ? { driverVersion: 'complete-driver' } : {})
|
||||
}))
|
||||
})),
|
||||
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
|
||||
|
||||
recorder.warm()
|
||||
await vi.waitFor(() => {
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
await Promise.resolve()
|
||||
await recorder.record()
|
||||
await recorder.record()
|
||||
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledOnce()
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledWith('complete')
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gpuInfoLevel: 'complete',
|
||||
gpuVendorId: 0x10de,
|
||||
gpuDeviceId: 0x2684,
|
||||
gpuDriverVersion: 'complete-driver'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses promptly available basic info when complete collection is still pending', async () => {
|
||||
const complete = deferred<unknown>()
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn((level: 'basic' | 'complete') =>
|
||||
level === 'basic' ? Promise.resolve(BASIC_INFO) : complete.promise
|
||||
),
|
||||
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
|
||||
|
||||
recorder.warm()
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledOnce()
|
||||
expect(provider.getGPUInfo).toHaveBeenCalledWith('complete')
|
||||
await recorder.record()
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gpuInfoLevel: 'basic',
|
||||
gpuVendorId: 0x10de,
|
||||
gpuDriverVersion: '32.0.15.6094'
|
||||
})
|
||||
)
|
||||
complete.resolve(BASIC_INFO)
|
||||
})
|
||||
|
||||
it('shares pending capture work and releases it when complete info arrives first', async () => {
|
||||
const complete = deferred<unknown>()
|
||||
const basic = deferred<unknown>()
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn((level: 'basic' | 'complete') =>
|
||||
level === 'basic' ? basic.promise : complete.promise
|
||||
),
|
||||
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
|
||||
|
||||
recorder.warm()
|
||||
const first = recorder.record()
|
||||
const second = recorder.record()
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(recordBreadcrumb).not.toHaveBeenCalled()
|
||||
complete.resolve(BASIC_INFO)
|
||||
await first
|
||||
expect(recordBreadcrumb).toHaveBeenCalledOnce()
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gpuInfoLevel: 'complete' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let stalled GPU info block crash recovery', async () => {
|
||||
const never = Promise.withResolvers<unknown>().promise
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn(() => never),
|
||||
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({
|
||||
provider,
|
||||
recordBreadcrumb,
|
||||
recordTimeoutMs: 0
|
||||
})
|
||||
|
||||
await recorder.record()
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gpuInfoLevel: 'unavailable' })
|
||||
)
|
||||
})
|
||||
|
||||
it('still records collection status when Electron throws during both GPU info calls', async () => {
|
||||
const recordBreadcrumb = vi.fn()
|
||||
const provider = {
|
||||
getGPUInfo: vi.fn(() => {
|
||||
throw new Error('GPU access disabled')
|
||||
}),
|
||||
getGPUFeatureStatus: vi.fn(() => {
|
||||
throw new Error('GPU teardown')
|
||||
})
|
||||
}
|
||||
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
|
||||
|
||||
recorder.warm()
|
||||
await recorder.record()
|
||||
|
||||
expect(recordBreadcrumb).toHaveBeenCalledWith({ gpuInfoLevel: 'unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GPU crash diagnostics production wiring', () => {
|
||||
it('starts diagnostics without delaying safe-graphics fallback', () => {
|
||||
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
const listenerStart = source.indexOf("app.on('child-process-gone'")
|
||||
expect(listenerStart).toBeGreaterThan(0)
|
||||
const listener = source.slice(listenerStart, source.indexOf('\n })', listenerStart))
|
||||
expect(source).toMatch(
|
||||
/recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/
|
||||
)
|
||||
expect(listener).toMatch(
|
||||
/const crashedAt = performance\.now\(\)[\s\S]*?void gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/
|
||||
)
|
||||
expect(listener).not.toMatch(/gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting'
|
||||
|
||||
type GpuInfoLevel = 'basic' | 'complete'
|
||||
const DEFAULT_GPU_CRASH_DIAGNOSTICS_WAIT_MS = 1_000
|
||||
|
||||
type GpuInfoProvider = {
|
||||
getGPUInfo(infoType: GpuInfoLevel): Promise<unknown>
|
||||
getGPUFeatureStatus(): unknown
|
||||
}
|
||||
|
||||
type GpuCrashDiagnosticsRecorderOptions = {
|
||||
provider: GpuInfoProvider
|
||||
recordBreadcrumb: (data: CrashReportBreadcrumbData) => void
|
||||
recordTimeoutMs?: number
|
||||
}
|
||||
|
||||
type GpuInfoSnapshot = {
|
||||
info: unknown
|
||||
level: GpuInfoLevel
|
||||
}
|
||||
|
||||
async function waitAtMost(promise: Promise<void>, timeoutMs: number): Promise<void> {
|
||||
const timeoutGate = Promise.withResolvers<void>()
|
||||
const timeout = setTimeout(timeoutGate.resolve, timeoutMs)
|
||||
try {
|
||||
await Promise.race([promise, timeoutGate.promise])
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function waitForFirstAvailable(promises: Promise<boolean>[]): Promise<void> {
|
||||
const availableGate = Promise.withResolvers<void>()
|
||||
let remaining = promises.length
|
||||
for (const promise of promises) {
|
||||
void promise.then((available) => {
|
||||
remaining -= 1
|
||||
if (available || remaining === 0) {
|
||||
availableGate.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
return availableGate.promise
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function addString(target: CrashReportBreadcrumbData, key: string, value: unknown): void {
|
||||
const safe = nonEmptyString(value)
|
||||
if (safe !== undefined) {
|
||||
target[key] = safe
|
||||
}
|
||||
}
|
||||
|
||||
function addNumber(target: CrashReportBreadcrumbData, key: string, value: unknown): void {
|
||||
const safe = finiteNumber(value)
|
||||
if (safe !== undefined) {
|
||||
target[key] = safe
|
||||
}
|
||||
}
|
||||
|
||||
function activeGpuDevice(info: Record<string, unknown>): {
|
||||
device: Record<string, unknown> | null
|
||||
count: number
|
||||
} {
|
||||
const devices = Array.isArray(info.gpuDevice)
|
||||
? info.gpuDevice.map(recordValue).filter((device) => device !== null)
|
||||
: []
|
||||
return {
|
||||
device: devices.find((device) => device.active === true) ?? devices[0] ?? null,
|
||||
count: devices.length
|
||||
}
|
||||
}
|
||||
|
||||
function addFeatureStatuses(details: CrashReportBreadcrumbData, featureStatus: unknown): void {
|
||||
const status = recordValue(featureStatus)
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
addString(details, 'gpuCompositingStatus', status.gpu_compositing)
|
||||
addString(details, 'gpuRasterizationStatus', status.rasterization)
|
||||
addString(details, 'gpuWebglStatus', status.webgl)
|
||||
addString(details, 'gpuWebgl2Status', status.webgl2)
|
||||
addString(details, 'gpuVideoDecodeStatus', status.video_decode)
|
||||
}
|
||||
|
||||
export function buildGpuCrashDiagnostics(
|
||||
snapshot: GpuInfoSnapshot | null,
|
||||
featureStatus: unknown
|
||||
): CrashReportBreadcrumbData {
|
||||
const details: CrashReportBreadcrumbData = {
|
||||
gpuInfoLevel: snapshot?.level ?? 'unavailable'
|
||||
}
|
||||
addFeatureStatuses(details, featureStatus)
|
||||
const info = recordValue(snapshot?.info)
|
||||
if (!info) {
|
||||
return details
|
||||
}
|
||||
|
||||
const { device, count } = activeGpuDevice(info)
|
||||
details.gpuDeviceCount = count
|
||||
if (device) {
|
||||
addNumber(details, 'gpuVendorId', device.vendorId)
|
||||
addNumber(details, 'gpuDeviceId', device.deviceId)
|
||||
addString(details, 'gpuVendor', device.vendorString)
|
||||
addString(details, 'gpuDevice', device.deviceString)
|
||||
addString(details, 'gpuDriverVendor', device.driverVendor)
|
||||
addString(details, 'gpuDriverVersion', device.driverVersion)
|
||||
}
|
||||
|
||||
const aux = recordValue(info.auxAttributes)
|
||||
if (aux) {
|
||||
addString(details, 'gpuGlVendor', aux.glVendor)
|
||||
addString(details, 'gpuGlRenderer', aux.glRenderer)
|
||||
addString(details, 'gpuGlVersion', aux.glVersion)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
/** Captures GPU identity before a crash and emits it once when a Windows GPU burst starts. */
|
||||
export class GpuCrashDiagnosticsRecorder {
|
||||
private readonly provider: GpuInfoProvider
|
||||
private readonly recordBreadcrumb: (data: CrashReportBreadcrumbData) => void
|
||||
private readonly recordTimeoutMs: number
|
||||
private basicInfoPromise: Promise<boolean> | null = null
|
||||
private completeInfoPromise: Promise<boolean> | null = null
|
||||
private recordingPromise: Promise<void> | null = null
|
||||
private basicInfo: unknown = null
|
||||
private completeInfo: unknown = null
|
||||
|
||||
constructor(options: GpuCrashDiagnosticsRecorderOptions) {
|
||||
this.provider = options.provider
|
||||
this.recordBreadcrumb = options.recordBreadcrumb
|
||||
this.recordTimeoutMs = options.recordTimeoutMs ?? DEFAULT_GPU_CRASH_DIAGNOSTICS_WAIT_MS
|
||||
}
|
||||
|
||||
warm(): void {
|
||||
void this.ensureCompleteInfo()
|
||||
}
|
||||
|
||||
record(): Promise<void> {
|
||||
this.recordingPromise ??= this.recordOnce()
|
||||
return this.recordingPromise
|
||||
}
|
||||
|
||||
private async recordOnce(): Promise<void> {
|
||||
let featureStatus: unknown = null
|
||||
try {
|
||||
featureStatus = this.provider.getGPUFeatureStatus()
|
||||
} catch {
|
||||
// GPU teardown can race this read; device identity is still useful.
|
||||
}
|
||||
if (this.completeInfo === null) {
|
||||
await waitAtMost(
|
||||
waitForFirstAvailable([this.ensureBasicInfo(), this.ensureCompleteInfo()]),
|
||||
this.recordTimeoutMs
|
||||
)
|
||||
}
|
||||
const snapshot = this.preferredSnapshot()
|
||||
try {
|
||||
this.recordBreadcrumb(buildGpuCrashDiagnostics(snapshot, featureStatus))
|
||||
} catch {
|
||||
// Diagnostics must never block safe-graphics recovery.
|
||||
}
|
||||
}
|
||||
|
||||
private ensureBasicInfo(): Promise<boolean> {
|
||||
if (this.basicInfoPromise === null) {
|
||||
try {
|
||||
this.basicInfoPromise = this.provider.getGPUInfo('basic').then(
|
||||
(info) => {
|
||||
this.basicInfo = info
|
||||
return true
|
||||
},
|
||||
() => false
|
||||
)
|
||||
} catch {
|
||||
this.basicInfoPromise = Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
return this.basicInfoPromise
|
||||
}
|
||||
|
||||
private ensureCompleteInfo(): Promise<boolean> {
|
||||
if (this.completeInfoPromise === null) {
|
||||
try {
|
||||
this.completeInfoPromise = this.provider.getGPUInfo('complete').then(
|
||||
(info) => {
|
||||
this.completeInfo = info
|
||||
return true
|
||||
},
|
||||
() => false
|
||||
)
|
||||
} catch {
|
||||
this.completeInfoPromise = Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
return this.completeInfoPromise
|
||||
}
|
||||
|
||||
private preferredSnapshot(): GpuInfoSnapshot | null {
|
||||
if (this.completeInfo !== null) {
|
||||
return { info: this.completeInfo, level: 'complete' }
|
||||
}
|
||||
if (this.basicInfo !== null) {
|
||||
return { info: this.basicInfo, level: 'basic' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
|
||||
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
||||
GpuCrashFallbackTracker,
|
||||
isGpuFallbackCrashCandidate
|
||||
} from './gpu-crash-fallback-decision'
|
||||
import { shouldRecordProcessGoneCrash } from './process-gone-classification'
|
||||
|
||||
/**
|
||||
* Replays the win32 GPU-child deaths from the 1.4.190 'crashed' renderer cluster
|
||||
* through the production decision path, so the reason no host was ever offered
|
||||
* Safe Graphics Mode is pinned rather than argued about.
|
||||
*
|
||||
* Each session below is one crash report's `Recent activity`; a breadcrumb's
|
||||
* `suppressedSinceLast=N` means N further identical GPU deaths were coalesced
|
||||
* into it, so the session saw N+1 GPU crashes.
|
||||
*/
|
||||
|
||||
type FieldSession = {
|
||||
/** Crash-report id from the field bundle. */
|
||||
report: string
|
||||
/** GPU child crash times, ms since main-process start. */
|
||||
gpuCrashesMsSinceLaunch: number[]
|
||||
}
|
||||
|
||||
// crashed.txt: every win32 report in the cluster. Times are (breadcrumb ts -
|
||||
// mainProcessStartedAt); coalesced repeats are placed inside the same second,
|
||||
// which is the only interval the emitted breadcrumb pins them to.
|
||||
const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [
|
||||
// 23:36:54.200 - 23:36:49.931, suppressedSinceLast=1 -> 2 crashes
|
||||
{ report: 'db1f1ee2', gpuCrashesMsSinceLaunch: [4_269, 4_800] },
|
||||
// 02:58:25.287 - 02:58:20.749, no suppression -> 1 crash
|
||||
{ report: '66cc54d8', gpuCrashesMsSinceLaunch: [4_538] },
|
||||
// 21:47:05.776 - 21:46:59.584, suppressedSinceLast=1 -> 2 crashes
|
||||
{ report: '1f5564de', gpuCrashesMsSinceLaunch: [6_192, 6_240] },
|
||||
// 00:17:17.840 - 00:17:15.732, no suppression -> 1 crash
|
||||
{ report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] }
|
||||
]
|
||||
|
||||
/** index.ts's `child-process-gone` listener body — the wiring these claims rest on. */
|
||||
function readChildProcessGoneListener(): string {
|
||||
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
const start = source.indexOf("app.on('child-process-gone'")
|
||||
expect(start).toBeGreaterThan(0)
|
||||
return source.slice(start, source.indexOf('\n })', start))
|
||||
}
|
||||
|
||||
function newTracker(): GpuCrashFallbackTracker {
|
||||
return new GpuCrashFallbackTracker({
|
||||
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
||||
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
|
||||
})
|
||||
}
|
||||
|
||||
const FIELD_GPU_EVENT = {
|
||||
source: 'child',
|
||||
processType: 'GPU',
|
||||
serviceName: 'GPU',
|
||||
reason: 'crashed',
|
||||
expectedTeardown: 'none'
|
||||
} as const
|
||||
|
||||
describe('1.4.190 win32 GPU-child crash cluster', () => {
|
||||
it('does not let process_gone_suppressed gate the fallback candidate check', () => {
|
||||
// The GPU death is suppressed as recoverable churn (no user-facing report)...
|
||||
expect(shouldRecordProcessGoneCrash({ ...FIELD_GPU_EVENT, exitCode: -2147483645 })).toBe(false)
|
||||
// ...but the fallback path reads the raw child-process-gone event, so the
|
||||
// suppression cannot hide a broken driver from recovery.
|
||||
expect(
|
||||
isGpuFallbackCrashCandidate({
|
||||
platform: 'win32',
|
||||
processType: 'GPU',
|
||||
reason: 'crashed'
|
||||
})
|
||||
).toBe(true)
|
||||
// Both assertions above still pass if the candidate check is moved behind the
|
||||
// suppressed-report path, so pin that nothing branches ahead of it in index.ts.
|
||||
const listener = readChildProcessGoneListener()
|
||||
const guardStart = listener.indexOf('isGpuFallbackCrashCandidate(')
|
||||
expect(guardStart).toBeGreaterThan(0)
|
||||
expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1)
|
||||
expect(listener).toMatch(
|
||||
/isGpuFallbackCrashCandidate\([\s\S]*?gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/
|
||||
)
|
||||
// The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which
|
||||
// re-couples recovery to the suppression decision, so pin the guard to that check alone.
|
||||
const recoveryGuard = listener.slice(
|
||||
listener.lastIndexOf('if (', guardStart),
|
||||
listener.indexOf('handleGpuChildCrash(')
|
||||
)
|
||||
expect(recoveryGuard).not.toMatch(/&&|\|\|/)
|
||||
})
|
||||
|
||||
it('never reaches the burst threshold in any observed cluster session', () => {
|
||||
const outcomes = CRASHED_CLUSTER_SESSIONS.map((session) => {
|
||||
// Each launch constructs a fresh tracker (src/main/index.ts), so evidence
|
||||
// does not survive the relaunch these users performed after every crash.
|
||||
const tracker = newTracker()
|
||||
const engaged = session.gpuCrashesMsSinceLaunch.some(
|
||||
(at) => tracker.recordGpuCrash(at).shouldEngageFallback
|
||||
)
|
||||
return {
|
||||
report: session.report,
|
||||
gpuCrashes: session.gpuCrashesMsSinceLaunch.length,
|
||||
engagedSafeGraphicsPrompt: engaged
|
||||
}
|
||||
})
|
||||
expect(outcomes).toEqual([
|
||||
{ report: 'db1f1ee2', gpuCrashes: 2, engagedSafeGraphicsPrompt: false },
|
||||
{ report: '66cc54d8', gpuCrashes: 1, engagedSafeGraphicsPrompt: false },
|
||||
{ report: '1f5564de', gpuCrashes: 2, engagedSafeGraphicsPrompt: false },
|
||||
{ report: '96d8c63b', gpuCrashes: 1, engagedSafeGraphicsPrompt: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('engages on the session that did reach three crashes (field launch 51b9e93c)', () => {
|
||||
// oom.txt, win32: GPU crashed/exitCode=34 with suppressedSinceLast=2, then
|
||||
// `gpu_fallback_engaged (crashesInWindow=3)` and `gpu_fallback_restart_deferred`.
|
||||
const tracker = newTracker()
|
||||
expect(tracker.recordGpuCrash(3_600_000).shouldEngageFallback).toBe(false)
|
||||
expect(tracker.recordGpuCrash(3_601_000).shouldEngageFallback).toBe(false)
|
||||
expect(tracker.recordGpuCrash(3_601_890)).toEqual({
|
||||
shouldEngageFallback: true,
|
||||
crashesInWindow: 3
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
engageGpuFallbackAfterCrashBurst,
|
||||
type GpuFallbackEngagement,
|
||||
type GpuFallbackEngagementHandlers
|
||||
} from './gpu-fallback-engagement'
|
||||
import type { GpuFallbackRestartDecision } from './gpu-fallback-restart-prompt'
|
||||
|
||||
const ENGAGEMENT: GpuFallbackEngagement = {
|
||||
reason: 'crashed',
|
||||
// 0x80000003 STATUS_BREAKPOINT — the production Windows signature.
|
||||
exitCode: -2147483645,
|
||||
crashesInWindow: 3,
|
||||
engagedAt: 1_700_000_000_000
|
||||
}
|
||||
|
||||
function createHandlers(overrides: Partial<GpuFallbackEngagementHandlers> = {}): {
|
||||
handlers: GpuFallbackEngagementHandlers
|
||||
order: string[]
|
||||
} {
|
||||
const order: string[] = []
|
||||
const handlers: GpuFallbackEngagementHandlers = {
|
||||
isQuitting: () => false,
|
||||
persistMarker: vi.fn(() => {
|
||||
order.push('persistMarker')
|
||||
return true
|
||||
}),
|
||||
confirmMarker: vi.fn(() => order.push('confirmMarker')),
|
||||
clearMarker: vi.fn(() => order.push('clearMarker')),
|
||||
promptForRestart: vi.fn(async () => {
|
||||
order.push('prompt')
|
||||
return 'restart' as GpuFallbackRestartDecision
|
||||
}),
|
||||
onPromptFailed: vi.fn(),
|
||||
onEngaged: vi.fn(),
|
||||
onRestartDeferred: vi.fn(() => order.push('restartDeferred')),
|
||||
restartIntoSafeGraphics: vi.fn(() => order.push('restart')),
|
||||
...overrides
|
||||
}
|
||||
return { handlers, order }
|
||||
}
|
||||
|
||||
describe('engageGpuFallbackAfterCrashBurst', () => {
|
||||
// Repro for the Windows startup GPU cluster (v1.4.190, exit -2147483645).
|
||||
// Measured on Windows 11 26200 / Electron 43.1.0: Chromium's own ladder aborts
|
||||
// the whole browser ("GPU process isn't usable. Goodbye.") on the 6th GPU
|
||||
// crash, 1.285s after the 3rd — the crash that trips this threshold. If the
|
||||
// marker is only written after the user answers the modal, the app dies first
|
||||
// and the next launch retries hardware acceleration, looping forever.
|
||||
it('persists the safe-graphics marker before the restart prompt is answered', async () => {
|
||||
let resolvePrompt: ((decision: GpuFallbackRestartDecision) => void) | undefined
|
||||
const { handlers } = createHandlers({
|
||||
promptForRestart: vi.fn(
|
||||
() =>
|
||||
new Promise<GpuFallbackRestartDecision>((resolve) => {
|
||||
resolvePrompt = resolve
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const engaging = engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
await Promise.resolve()
|
||||
|
||||
// Chromium kills the process here; whatever is on disk now is all the next launch gets.
|
||||
expect(handlers.persistMarker).toHaveBeenCalledWith(ENGAGEMENT)
|
||||
|
||||
resolvePrompt?.('restart')
|
||||
await engaging
|
||||
})
|
||||
|
||||
it('relaunches into safe graphics when the user accepts', async () => {
|
||||
const { handlers, order } = createHandlers()
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(order).toEqual(['persistMarker', 'prompt', 'confirmMarker', 'restart'])
|
||||
expect(handlers.clearMarker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('undoes the marker when the user chooses Keep Running', async () => {
|
||||
const { handlers, order } = createHandlers({
|
||||
promptForRestart: vi.fn(async () => 'continue' as GpuFallbackRestartDecision)
|
||||
})
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(order).toEqual(['persistMarker', 'clearMarker', 'restartDeferred'])
|
||||
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the marker when the prompt itself fails, so the next launch is still safe', async () => {
|
||||
const error = new Error('no display')
|
||||
const { handlers } = createHandlers({
|
||||
promptForRestart: vi.fn(async () => {
|
||||
throw error
|
||||
})
|
||||
})
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(handlers.persistMarker).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.clearMarker).not.toHaveBeenCalled()
|
||||
expect(handlers.onPromptFailed).toHaveBeenCalledWith(error)
|
||||
})
|
||||
|
||||
it('does not relaunch when a quit began while the prompt was open', async () => {
|
||||
const { handlers } = createHandlers({ isQuitting: () => true })
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
|
||||
// The marker stays: a quit is not the user declining safe graphics.
|
||||
expect(handlers.clearMarker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim a restart when the marker could not be written', async () => {
|
||||
const { handlers } = createHandlers({ persistMarker: vi.fn(() => false) })
|
||||
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
|
||||
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { GpuFallbackRestartDecision } from './gpu-fallback-restart-prompt'
|
||||
|
||||
/** The GPU-crash burst that tripped the software-rendering fallback. */
|
||||
export type GpuFallbackEngagement = {
|
||||
reason: string
|
||||
exitCode: number | null
|
||||
crashesInWindow: number
|
||||
/** Wall-clock ms stamped into the persisted marker. */
|
||||
engagedAt: number
|
||||
}
|
||||
|
||||
export type GpuFallbackEngagementHandlers = {
|
||||
isQuitting: () => boolean
|
||||
/** Writes the build-scoped safe-graphics marker; false when it could not be persisted. */
|
||||
persistMarker: (engagement: GpuFallbackEngagement) => boolean
|
||||
/** Marks that the user explicitly accepted safe graphics before relaunching. */
|
||||
confirmMarker: (engagement: GpuFallbackEngagement) => void
|
||||
clearMarker: () => void
|
||||
promptForRestart: () => Promise<GpuFallbackRestartDecision>
|
||||
onPromptFailed: (error: unknown) => void
|
||||
onEngaged: (engagement: GpuFallbackEngagement) => void
|
||||
onRestartDeferred: (engagement: GpuFallbackEngagement) => void
|
||||
restartIntoSafeGraphics: (engagement: GpuFallbackEngagement) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the safe-graphics handover once a GPU crash burst trips the threshold.
|
||||
*
|
||||
* Why the marker is written before the prompt: measured on Windows 11 26200 /
|
||||
* Electron 43.1.0, Chromium's own GPU ladder aborts the whole browser process
|
||||
* (`FATAL gpu_data_manager_impl_private.cc: GPU process isn't usable. Goodbye.`)
|
||||
* on the 6th GPU crash — 1.285s after the 3rd, which is the crash that trips
|
||||
* this threshold. Persisting only after the user answers a modal means the app
|
||||
* dies first, no marker survives, and the next launch retries hardware
|
||||
* acceleration: the crash loop never ends. An explicit "Keep Running" undoes it.
|
||||
*/
|
||||
export async function engageGpuFallbackAfterCrashBurst(
|
||||
engagement: GpuFallbackEngagement,
|
||||
handlers: GpuFallbackEngagementHandlers
|
||||
): Promise<void> {
|
||||
handlers.onEngaged(engagement)
|
||||
const persisted = handlers.persistMarker(engagement)
|
||||
let decision: GpuFallbackRestartDecision
|
||||
try {
|
||||
decision = await handlers.promptForRestart()
|
||||
} catch (error) {
|
||||
// Marker stays: the next launch is safe even though the user was never asked.
|
||||
handlers.onPromptFailed(error)
|
||||
return
|
||||
}
|
||||
if (handlers.isQuitting()) {
|
||||
return
|
||||
}
|
||||
if (decision !== 'restart') {
|
||||
if (persisted) {
|
||||
handlers.clearMarker()
|
||||
}
|
||||
handlers.onRestartDeferred(engagement)
|
||||
return
|
||||
}
|
||||
if (!persisted) {
|
||||
return
|
||||
}
|
||||
handlers.confirmMarker(engagement)
|
||||
handlers.restartIntoSafeGraphics(engagement)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { showMessageBoxMock } = vi.hoisted(() => ({
|
||||
showMessageBoxMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
dialog: { showMessageBox: showMessageBoxMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
handleGpuFallbackRecoveredLaunch,
|
||||
promptForGpuFallbackRecoveredLaunch,
|
||||
type GpuFallbackRecoveredLaunchDecision,
|
||||
type GpuFallbackRecoveredLaunchHandlers
|
||||
} from './gpu-fallback-recovered-launch'
|
||||
|
||||
beforeEach(() => {
|
||||
showMessageBoxMock.mockReset()
|
||||
})
|
||||
|
||||
function createHandlers(
|
||||
decision: GpuFallbackRecoveredLaunchDecision = 'keep-safe',
|
||||
overrides: Partial<GpuFallbackRecoveredLaunchHandlers> = {}
|
||||
): { handlers: GpuFallbackRecoveredLaunchHandlers; order: string[] } {
|
||||
const order: string[] = []
|
||||
const handlers: GpuFallbackRecoveredLaunchHandlers = {
|
||||
isQuitting: () => false,
|
||||
prompt: vi.fn(async () => {
|
||||
order.push('prompt')
|
||||
return decision
|
||||
}),
|
||||
confirmSafeGraphics: vi.fn(() => order.push('confirm')),
|
||||
clearSafeGraphics: vi.fn(() => order.push('clear')),
|
||||
onPromptFailed: vi.fn(),
|
||||
onSafeGraphicsKept: vi.fn(() => order.push('kept')),
|
||||
restartWithHardware: vi.fn(() => order.push('restart')),
|
||||
...overrides
|
||||
}
|
||||
return { handlers, order }
|
||||
}
|
||||
|
||||
describe('promptForGpuFallbackRecoveredLaunch', () => {
|
||||
it('defaults dismissal to the stable safe-graphics choice', async () => {
|
||||
const parentWindow = { id: 1 }
|
||||
showMessageBoxMock.mockResolvedValue({ response: 0 })
|
||||
|
||||
await expect(promptForGpuFallbackRecoveredLaunch(parentWindow as never)).resolves.toBe(
|
||||
'keep-safe'
|
||||
)
|
||||
expect(showMessageBoxMock).toHaveBeenCalledWith(parentWindow, {
|
||||
type: 'info',
|
||||
buttons: ['Keep Safe Graphics Mode', 'Try Hardware Acceleration'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: 'Safe Graphics Mode is Active',
|
||||
message: 'Orca recovered in Safe Graphics Mode.',
|
||||
detail:
|
||||
'Safe Graphics Mode was enabled after repeated graphics crashes. Keep it for stability, or restart and try hardware acceleration again.'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the explicit hardware retry choice', async () => {
|
||||
showMessageBoxMock.mockResolvedValue({ response: 1 })
|
||||
await expect(promptForGpuFallbackRecoveredLaunch()).resolves.toBe('retry-hardware')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleGpuFallbackRecoveredLaunch', () => {
|
||||
it('confirms safe graphics so later launches do not prompt again', async () => {
|
||||
const { handlers, order } = createHandlers()
|
||||
await handleGpuFallbackRecoveredLaunch(handlers)
|
||||
expect(order).toEqual(['prompt', 'confirm', 'kept'])
|
||||
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
|
||||
expect(handlers.restartWithHardware).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the marker before restarting with hardware acceleration', async () => {
|
||||
const { handlers, order } = createHandlers('retry-hardware')
|
||||
await handleGpuFallbackRecoveredLaunch(handlers)
|
||||
expect(order).toEqual(['prompt', 'clear', 'restart'])
|
||||
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the unconfirmed marker intact when the prompt fails', async () => {
|
||||
const error = new Error('dialog failed')
|
||||
const { handlers } = createHandlers('keep-safe', {
|
||||
prompt: vi.fn(async () => {
|
||||
throw error
|
||||
})
|
||||
})
|
||||
await handleGpuFallbackRecoveredLaunch(handlers)
|
||||
expect(handlers.onPromptFailed).toHaveBeenCalledWith(error)
|
||||
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
|
||||
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mutate the marker when shutdown starts while the prompt is open', async () => {
|
||||
const { handlers } = createHandlers('retry-hardware', { isQuitting: () => true })
|
||||
await handleGpuFallbackRecoveredLaunch(handlers)
|
||||
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
|
||||
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
|
||||
expect(handlers.restartWithHardware).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recovered safe-graphics production wiring', () => {
|
||||
it('prompts only after the recovered window is shown and persists both consent states', () => {
|
||||
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
expect(source).toMatch(
|
||||
/window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { dialog, type BrowserWindow, type MessageBoxOptions } from 'electron'
|
||||
|
||||
export type GpuFallbackRecoveredLaunchDecision = 'keep-safe' | 'retry-hardware'
|
||||
|
||||
const GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS: MessageBoxOptions = {
|
||||
type: 'info',
|
||||
buttons: ['Keep Safe Graphics Mode', 'Try Hardware Acceleration'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: 'Safe Graphics Mode is Active',
|
||||
message: 'Orca recovered in Safe Graphics Mode.',
|
||||
detail:
|
||||
'Safe Graphics Mode was enabled after repeated graphics crashes. Keep it for stability, or restart and try hardware acceleration again.'
|
||||
}
|
||||
|
||||
export async function promptForGpuFallbackRecoveredLaunch(
|
||||
parentWindow?: BrowserWindow
|
||||
): Promise<GpuFallbackRecoveredLaunchDecision> {
|
||||
const { response } = parentWindow
|
||||
? await dialog.showMessageBox(parentWindow, GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS)
|
||||
: await dialog.showMessageBox(GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS)
|
||||
return response === 1 ? 'retry-hardware' : 'keep-safe'
|
||||
}
|
||||
|
||||
export type GpuFallbackRecoveredLaunchHandlers = {
|
||||
isQuitting: () => boolean
|
||||
prompt: () => Promise<GpuFallbackRecoveredLaunchDecision>
|
||||
confirmSafeGraphics: () => void
|
||||
clearSafeGraphics: () => void
|
||||
onPromptFailed: (error: unknown) => void
|
||||
onSafeGraphicsKept: () => void
|
||||
restartWithHardware: () => void
|
||||
}
|
||||
|
||||
/** Resolves consent after an unanswered crash-time prompt recovered into safe graphics. */
|
||||
export async function handleGpuFallbackRecoveredLaunch(
|
||||
handlers: GpuFallbackRecoveredLaunchHandlers
|
||||
): Promise<void> {
|
||||
let decision: GpuFallbackRecoveredLaunchDecision
|
||||
try {
|
||||
decision = await handlers.prompt()
|
||||
} catch (error) {
|
||||
handlers.onPromptFailed(error)
|
||||
return
|
||||
}
|
||||
if (handlers.isQuitting()) {
|
||||
return
|
||||
}
|
||||
if (decision === 'retry-hardware') {
|
||||
handlers.clearSafeGraphics()
|
||||
handlers.restartWithHardware()
|
||||
return
|
||||
}
|
||||
handlers.confirmSafeGraphics()
|
||||
handlers.onSafeGraphicsKept()
|
||||
}
|
||||
@@ -22,6 +22,19 @@ describe('parseOrcaYaml', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a project requirement to finish setup before agent startup', () => {
|
||||
const yaml = [
|
||||
'setupAgentStartupPolicy: wait-for-setup',
|
||||
'scripts:',
|
||||
' setup: node install-project-skills.mjs'
|
||||
].join('\n')
|
||||
|
||||
expect(parseOrcaYaml(yaml)).toEqual({
|
||||
scripts: { setup: 'node install-project-skills.mjs' },
|
||||
setupAgentStartupPolicy: 'wait-for-setup'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses YAML with archive script only', () => {
|
||||
const yaml = `scripts:\n archive: |\n echo "archiving"\n`
|
||||
const result = parseOrcaYaml(yaml)
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('createSetupRunnerScript', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => {
|
||||
it('waits when either local or project policy requires completed setup', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
|
||||
const { createSetupRunnerScript } = await import('./worktree-runner-script')
|
||||
@@ -318,6 +318,26 @@ describe('createSetupRunnerScript', () => {
|
||||
createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup')
|
||||
.waitForAgentStartup
|
||||
).toBe(true)
|
||||
expect(
|
||||
createSetupRunnerScript(
|
||||
makeRepo('start-immediately'),
|
||||
'/test/worktree',
|
||||
'echo setup',
|
||||
undefined,
|
||||
undefined,
|
||||
'wait-for-setup'
|
||||
).waitForAgentStartup
|
||||
).toBe(true)
|
||||
expect(
|
||||
createSetupRunnerScript(
|
||||
makeRepo('wait-for-setup'),
|
||||
'/test/worktree',
|
||||
'echo setup',
|
||||
undefined,
|
||||
undefined,
|
||||
'start-immediately'
|
||||
).waitForAgentStartup
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('marks setup-runner terminals for the always-on credential guard', async () => {
|
||||
|
||||
@@ -55,6 +55,7 @@ export function hasHooksFile(repoPath: string): boolean {
|
||||
// Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
|
||||
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
|
||||
'scripts',
|
||||
'setupAgentStartupPolicy',
|
||||
'issueCommand',
|
||||
'defaultTabs',
|
||||
'environmentRecipes',
|
||||
|
||||
+131
-49
@@ -154,6 +154,7 @@ import {
|
||||
configureDevUserDataPath,
|
||||
configureOrcaUserDataPathEnv,
|
||||
disableUnsupportedChromiumFeatures,
|
||||
optOutOfHiddenPageWakeUpThrottling,
|
||||
enableMainProcessGpuFeatures,
|
||||
installDevParentDisconnectQuit,
|
||||
installDevParentSignalQuit,
|
||||
@@ -170,8 +171,10 @@ import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom'
|
||||
import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv'
|
||||
import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display'
|
||||
import {
|
||||
clearGpuFallbackMarker,
|
||||
readActiveGpuFallbackMarker,
|
||||
writeGpuFallbackMarker,
|
||||
type GpuFallbackMarker,
|
||||
type GpuFallbackEnvironment,
|
||||
type WindowsGpuFallbackEnvironment
|
||||
} from './startup/gpu-fallback-marker'
|
||||
@@ -182,10 +185,13 @@ import {
|
||||
GpuCrashFallbackTracker,
|
||||
isGpuFallbackCrashCandidate
|
||||
} from './crash-reporting/gpu-crash-fallback-decision'
|
||||
import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt'
|
||||
import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement'
|
||||
import { GpuCrashDiagnosticsRecorder } from './crash-reporting/gpu-crash-diagnostics'
|
||||
import {
|
||||
promptForGpuFallbackRestart,
|
||||
type GpuFallbackRestartDecision
|
||||
} from './crash-reporting/gpu-fallback-restart-prompt'
|
||||
handleGpuFallbackRecoveredLaunch,
|
||||
promptForGpuFallbackRecoveredLaunch
|
||||
} from './crash-reporting/gpu-fallback-recovered-launch'
|
||||
import {
|
||||
shouldSuppressDevEducation,
|
||||
suppressDevEducationForStore
|
||||
@@ -462,8 +468,19 @@ const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({
|
||||
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
|
||||
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
|
||||
})
|
||||
let activeGpuFallbackMarker: GpuFallbackMarker | null = null
|
||||
let gpuFallbackActiveThisLaunch = false
|
||||
let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null
|
||||
const gpuCrashDiagnostics =
|
||||
process.platform === 'win32'
|
||||
? new GpuCrashDiagnosticsRecorder({
|
||||
provider: {
|
||||
getGPUInfo: (infoType) => app.getGPUInfo(infoType),
|
||||
getGPUFeatureStatus: () => app.getGPUFeatureStatus()
|
||||
},
|
||||
recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data)
|
||||
})
|
||||
: null
|
||||
let localPtyStartupReady: Promise<void> = Promise.resolve()
|
||||
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
|
||||
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
||||
@@ -550,6 +567,7 @@ function updateGpuAccelerationAboutPanel(): void {
|
||||
|
||||
app.on('gpu-info-update', () => {
|
||||
gpuFeatureStatus = app.getGPUFeatureStatus()
|
||||
gpuCrashDiagnostics?.warm()
|
||||
if (app.isReady()) {
|
||||
updateGpuAccelerationAboutPanel()
|
||||
}
|
||||
@@ -982,6 +1000,8 @@ if (hasSingleInstanceLock) {
|
||||
...getMainProcessLifecycleIdentity()
|
||||
})
|
||||
disableUnsupportedChromiumFeatures()
|
||||
// Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below.
|
||||
optOutOfHiddenPageWakeUpThrottling()
|
||||
configureElectronNetworkCompatibility()
|
||||
enableRendererHeapHeadroom()
|
||||
maybeApplyGpuFallbackForThisLaunch()
|
||||
@@ -1573,6 +1593,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
|
||||
})
|
||||
window.once('show', () => {
|
||||
logStartupMilestone('window-shown')
|
||||
void presentGpuFallbackRecoveredLaunchPrompt(window)
|
||||
})
|
||||
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
|
||||
trayCreateFallback.unref?.()
|
||||
@@ -1859,7 +1880,25 @@ function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | nul
|
||||
return { ...environment, platform: 'win32' }
|
||||
}
|
||||
|
||||
// Why: read the GPU-fallback marker before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only.
|
||||
// Writes both crash-time and post-recovery consent states through one build-scoped path.
|
||||
function persistGpuFallbackMarker(
|
||||
userDataPath: string,
|
||||
info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean }
|
||||
): boolean {
|
||||
const environment = getWindowsGpuFallbackEnvironment()
|
||||
if (!environment) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
writeGpuFallbackMarker(userDataPath, info, environment)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[gpu-fallback] failed to persist marker:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only.
|
||||
function maybeApplyGpuFallbackForThisLaunch(): void {
|
||||
if (isServeMode || process.platform !== 'win32') {
|
||||
return
|
||||
@@ -1868,6 +1907,7 @@ function maybeApplyGpuFallbackForThisLaunch(): void {
|
||||
if (!marker) {
|
||||
return
|
||||
}
|
||||
activeGpuFallbackMarker = marker
|
||||
app.disableHardwareAcceleration()
|
||||
const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform)
|
||||
gpuFallbackActiveThisLaunch = true
|
||||
@@ -1879,64 +1919,104 @@ function maybeApplyGpuFallbackForThisLaunch(): void {
|
||||
})
|
||||
}
|
||||
|
||||
async function presentGpuFallbackRecoveredLaunchPrompt(window: BrowserWindow): Promise<void> {
|
||||
const marker = activeGpuFallbackMarker
|
||||
if (!marker || marker.userConfirmed || window.isDestroyed() || isQuitting) {
|
||||
return
|
||||
}
|
||||
// One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries.
|
||||
activeGpuFallbackMarker = null
|
||||
const userDataPath = app.getPath('userData')
|
||||
await handleGpuFallbackRecoveredLaunch({
|
||||
isQuitting: () => isQuitting,
|
||||
prompt: () => promptForGpuFallbackRecoveredLaunch(window),
|
||||
confirmSafeGraphics: () => {
|
||||
persistGpuFallbackMarker(userDataPath, {
|
||||
engagedAt: marker.engagedAt,
|
||||
crashesInWindow: marker.crashesInWindow,
|
||||
userConfirmed: true
|
||||
})
|
||||
},
|
||||
clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath),
|
||||
onPromptFailed: (error) =>
|
||||
console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error),
|
||||
onSafeGraphicsKept: () =>
|
||||
recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', {
|
||||
crashesInWindow: marker.crashesInWindow
|
||||
}),
|
||||
restartWithHardware: () => {
|
||||
isQuitting = true
|
||||
relaunchApp('gpu-fallback', {
|
||||
mode: 'hardware-retry',
|
||||
crashesInWindow: marker.crashesInWindow
|
||||
})
|
||||
destroySystemTray()
|
||||
app.exit(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering.
|
||||
async function handleGpuChildCrash(reason: string, exitCode: number | null): Promise<void> {
|
||||
async function handleGpuChildCrash(
|
||||
reason: string,
|
||||
exitCode: number | null,
|
||||
crashedAt: number
|
||||
): Promise<void> {
|
||||
// Software rendering already active or shutting down: nothing more to do.
|
||||
if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) {
|
||||
return
|
||||
}
|
||||
const result = gpuCrashFallbackTracker.recordGpuCrash(performance.now())
|
||||
const result = gpuCrashFallbackTracker.recordGpuCrash(crashedAt)
|
||||
if (!result.shouldEngageFallback) {
|
||||
return
|
||||
}
|
||||
recordCrashBreadcrumb('gpu_fallback_engaged', {
|
||||
reason,
|
||||
exitCode,
|
||||
crashesInWindow: result.crashesInWindow
|
||||
})
|
||||
const engagedAt = Date.now()
|
||||
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
||||
let restartDecision: GpuFallbackRestartDecision
|
||||
try {
|
||||
restartDecision = await promptForGpuFallbackRestart(window)
|
||||
} catch (error) {
|
||||
console.warn('[gpu-fallback] failed to show restart prompt:', error)
|
||||
return
|
||||
}
|
||||
const fallbackData = {
|
||||
processReason: reason,
|
||||
exitCode,
|
||||
crashesInWindow: result.crashesInWindow
|
||||
}
|
||||
if (isQuitting) {
|
||||
return
|
||||
}
|
||||
if (restartDecision !== 'restart') {
|
||||
recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData)
|
||||
return
|
||||
}
|
||||
const environment = getWindowsGpuFallbackEnvironment()
|
||||
if (!environment) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
writeGpuFallbackMarker(
|
||||
app.getPath('userData'),
|
||||
{
|
||||
engagedAt,
|
||||
crashesInWindow: result.crashesInWindow
|
||||
const userDataPath = app.getPath('userData')
|
||||
await engageGpuFallbackAfterCrashBurst(
|
||||
{ reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() },
|
||||
{
|
||||
isQuitting: () => isQuitting,
|
||||
onEngaged: (engagement) =>
|
||||
recordCrashBreadcrumb('gpu_fallback_engaged', {
|
||||
reason: engagement.reason,
|
||||
exitCode: engagement.exitCode,
|
||||
crashesInWindow: engagement.crashesInWindow
|
||||
}),
|
||||
persistMarker: (engagement) =>
|
||||
persistGpuFallbackMarker(userDataPath, {
|
||||
engagedAt: engagement.engagedAt,
|
||||
crashesInWindow: engagement.crashesInWindow,
|
||||
userConfirmed: false
|
||||
}),
|
||||
confirmMarker: (engagement) => {
|
||||
persistGpuFallbackMarker(userDataPath, {
|
||||
engagedAt: engagement.engagedAt,
|
||||
crashesInWindow: engagement.crashesInWindow,
|
||||
userConfirmed: true
|
||||
})
|
||||
},
|
||||
environment
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[gpu-fallback] failed to persist marker:', error)
|
||||
return
|
||||
}
|
||||
isQuitting = true
|
||||
relaunchApp('gpu-fallback', fallbackData)
|
||||
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
|
||||
destroySystemTray()
|
||||
app.exit(0)
|
||||
clearMarker: () => clearGpuFallbackMarker(userDataPath),
|
||||
promptForRestart: () =>
|
||||
promptForGpuFallbackRestart(
|
||||
mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
|
||||
),
|
||||
onPromptFailed: (error) =>
|
||||
console.warn('[gpu-fallback] failed to show restart prompt:', error),
|
||||
onRestartDeferred: () =>
|
||||
recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData),
|
||||
restartIntoSafeGraphics: () => {
|
||||
isQuitting = true
|
||||
relaunchApp('gpu-fallback', fallbackData)
|
||||
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
|
||||
destroySystemTray()
|
||||
app.exit(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordProcessGoneCrash(
|
||||
@@ -3155,7 +3235,9 @@ void app.whenReady().then(async () => {
|
||||
reason: details.reason
|
||||
})
|
||||
) {
|
||||
void handleGpuChildCrash(details.reason, details.exitCode ?? null)
|
||||
const crashedAt = performance.now()
|
||||
void gpuCrashDiagnostics?.record()
|
||||
void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Store } from '../persistence'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { SetupAgentStartupPolicy } from '../../shared/orca-yaml-hook-types'
|
||||
import type {
|
||||
LocalBaseRefRefreshResult,
|
||||
LocalBaseRefUpdateSuggestion
|
||||
@@ -1259,7 +1260,8 @@ async function createRemoteSetupRunnerScript(
|
||||
worktreePath: string,
|
||||
script: string,
|
||||
gitProvider: SshGitProvider,
|
||||
fsProvider: IFilesystemProvider
|
||||
fsProvider: IFilesystemProvider,
|
||||
projectStartupPolicy?: SetupAgentStartupPolicy
|
||||
): Promise<CreateWorktreeResult['setup']> {
|
||||
const useWindowsFormat = isWindowsAbsolutePathLike(worktreePath)
|
||||
// Why: SSH terminals choose their shell on the remote host; local Windows
|
||||
@@ -1281,7 +1283,10 @@ async function createRemoteSetupRunnerScript(
|
||||
return {
|
||||
runnerScriptPath,
|
||||
envVars: getSetupRunnerEnvVars(repo, worktreePath),
|
||||
...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
|
||||
...(shouldWaitForSetupBeforeAgentStartup(
|
||||
repo.hookSettings?.setupAgentStartupPolicy,
|
||||
projectStartupPolicy
|
||||
)
|
||||
? { waitForAgentStartup: true }
|
||||
: {})
|
||||
}
|
||||
@@ -2030,7 +2035,8 @@ export async function createRemoteWorktree(
|
||||
created.path,
|
||||
setupScript,
|
||||
provider,
|
||||
fsProvider
|
||||
fsProvider,
|
||||
yamlHooks?.setupAgentStartupPolicy
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error)
|
||||
@@ -2721,13 +2727,13 @@ export async function createLocalWorktree(
|
||||
try {
|
||||
// Why: main only writes the runner script and must not execute setup itself, or we reintroduce the old hidden background-hook behavior.
|
||||
// Why: worktree already exists, so a runner-gen failure degrades to "created without setup launch" rather than failing creation.
|
||||
// Why: both trailing args are optional — the shell is undefined off Windows.
|
||||
setup = createSetupRunnerScript(
|
||||
repo,
|
||||
worktreePath,
|
||||
setupScript,
|
||||
localWorktreeGitOptionArgs[0],
|
||||
resolveSetupRunnerShell(settings)
|
||||
resolveSetupRunnerShell(settings),
|
||||
createdYamlHooks?.setupAgentStartupPolicy
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
|
||||
|
||||
@@ -514,10 +514,14 @@ describe('registerWorktreeHandlers', () => {
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
loadHooksMock.mockReturnValue({
|
||||
scripts: { setup: 'pnpm install' },
|
||||
setupAgentStartupPolicy: 'wait-for-setup'
|
||||
})
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
expect(createSetupRunnerScriptMock).not.toHaveBeenCalled()
|
||||
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
@@ -538,6 +542,14 @@ describe('registerWorktreeHandlers', () => {
|
||||
startupTerminal?: { spawned: boolean; surface?: string }
|
||||
timing?: { phases: { phase: string }[] }
|
||||
}
|
||||
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm install',
|
||||
undefined,
|
||||
undefined,
|
||||
'wait-for-setup'
|
||||
)
|
||||
|
||||
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
|
||||
@@ -118,6 +118,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
@@ -169,6 +170,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup # worktree',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(result).toEqual(
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
}
|
||||
const fsProvider = {
|
||||
readFile: vi.fn().mockResolvedValue({
|
||||
content: 'scripts:\n setup: pnpm install\n',
|
||||
content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n',
|
||||
isBinary: false
|
||||
}),
|
||||
createDir: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -153,7 +153,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
parseOrcaYamlMock.mockReturnValue({
|
||||
scripts: { setup: 'pnpm install' },
|
||||
setupAgentStartupPolicy: 'wait-for-setup'
|
||||
})
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
|
||||
@@ -184,7 +187,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
envVars: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: '/remote/repo',
|
||||
ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard'
|
||||
})
|
||||
}),
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -467,7 +467,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
'C:\\workspaces\\improve-dashboard',
|
||||
'pnpm install',
|
||||
undefined,
|
||||
setupShell
|
||||
setupShell,
|
||||
undefined
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
setup: {
|
||||
|
||||
@@ -369,6 +369,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup',
|
||||
{ wslDistro: 'Ubuntu' },
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -60,7 +60,8 @@ function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): str
|
||||
})}\n`
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
||||
// fs.watch and filesystem mutations use the platform clock; poll observable callbacks to a deadline.
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
@@ -166,13 +167,14 @@ describe('native chat transcript watcher liveness', () => {
|
||||
onReplace: replacements,
|
||||
onAppend: () => {},
|
||||
debounceMs: 0,
|
||||
reconciliationIntervalMs: 20
|
||||
reconciliationIntervalMs: 10_000
|
||||
})
|
||||
await waitFor(() => snapshots.mock.calls.length === 1)
|
||||
|
||||
await writeFile(filePath, prefixAfter + stableTail)
|
||||
const future = new Date(Date.now() + 10_000)
|
||||
await utimes(filePath, future, future)
|
||||
watchCallbacks[0]!('change', 'transcript.jsonl')
|
||||
await waitFor(() =>
|
||||
replacements.mock.calls.flat(2).some((message) => message.id === 'prefix-new')
|
||||
)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as pty from 'node-pty'
|
||||
@@ -8,7 +16,11 @@ import { getPosixOmpShellWrapper } from './omp-shell-wrapper'
|
||||
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
|
||||
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
|
||||
const itWithBash = hasBash ? it : it.skip
|
||||
const itWithZsh = hasZsh ? it : it.skip
|
||||
|
||||
type PosixShell = 'bash' | 'zsh'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
@@ -31,33 +43,38 @@ fi
|
||||
{
|
||||
printf 'PI=%s\\n' "$PI_CODING_AGENT_DIR"
|
||||
printf 'EFFECTIVE=%s\\n' "$agent_dir"
|
||||
printf 'CWD=%s\\n' "$(pwd -P)"
|
||||
i=0
|
||||
for arg in "$@"; do
|
||||
i=$((i + 1))
|
||||
printf 'ARG%s=%s\\n' "$i" "$arg"
|
||||
done
|
||||
} > "$ORCA_CAPTURE_FILE"
|
||||
exit "\${ORCA_TEST_FAKE_OMP_EXIT_CODE:-0}"
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
chmodSync(ompPath, 0o755)
|
||||
}
|
||||
|
||||
async function runInteractiveBashPty(args: {
|
||||
async function runInteractivePosixPty(args: {
|
||||
rcfileContent: string
|
||||
env: Record<string, string>
|
||||
input: string
|
||||
cwd: string
|
||||
shell?: PosixShell
|
||||
}): Promise<string> {
|
||||
const rcfile = join(args.cwd, 'rcfile')
|
||||
writeFileSync(rcfile, args.rcfileContent)
|
||||
const shell = args.shell ?? 'bash'
|
||||
const shellArgs = shell === 'bash' ? ['--noprofile', '--rcfile', rcfile, '-i'] : ['-f', '-i']
|
||||
|
||||
const proc = pty.spawn('bash', ['--noprofile', '--rcfile', rcfile, '-i'], {
|
||||
const proc = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cwd: args.cwd,
|
||||
env: args.env
|
||||
env: { ...args.env, ORCA_TEST_RCFILE: rcfile }
|
||||
})
|
||||
|
||||
let output = ''
|
||||
@@ -72,13 +89,14 @@ async function runInteractiveBashPty(args: {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`timed out waiting for bash PTY output:\n${output}`)),
|
||||
() => reject(new Error(`timed out waiting for ${shell} PTY output:\n${output}`)),
|
||||
5000
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
proc.write(args.input.replace(/\n/g, '\r'))
|
||||
const input = shell === 'zsh' ? `source "$ORCA_TEST_RCFILE"\n${args.input}` : args.input
|
||||
proc.write(input.replace(/\n/g, '\r'))
|
||||
const { exitCode } = await Promise.race([exitPromise, timeoutPromise])
|
||||
expect(exitCode).toBe(0)
|
||||
return output
|
||||
@@ -130,7 +148,7 @@ describePosix('OMP shell wrapper node-pty reproduction', () => {
|
||||
|
||||
const unwrappedCapture = join(tempDir, 'unwrapped-capture')
|
||||
const unwrappedAfterPi = join(tempDir, 'unwrapped-after-pi')
|
||||
await runInteractiveBashPty({
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: '',
|
||||
env: makeEnv(unwrappedCapture, unwrappedAfterPi),
|
||||
@@ -148,7 +166,7 @@ exit 0
|
||||
|
||||
const wrappedCapture = join(tempDir, 'wrapped-capture')
|
||||
const wrappedAfterPi = join(tempDir, 'wrapped-after-pi')
|
||||
const wrappedOutput = await runInteractiveBashPty({
|
||||
const wrappedOutput = await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: makeEnv(wrappedCapture, wrappedAfterPi),
|
||||
@@ -182,7 +200,7 @@ exit 0
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const captureFile = join(tempDir, 'config-capture')
|
||||
await runInteractiveBashPty({
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: {
|
||||
@@ -237,7 +255,7 @@ exit 0
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const captureFile = join(tempDir, `${subcommand}-capture`)
|
||||
await runInteractiveBashPty({
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: {
|
||||
@@ -280,7 +298,7 @@ exit 0
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const captureFile = join(tempDir, 'default-config-capture')
|
||||
await runInteractiveBashPty({
|
||||
await runInteractivePosixPty({
|
||||
cwd: tempDir,
|
||||
rcfileContent: getPosixOmpShellWrapper(),
|
||||
env: {
|
||||
@@ -308,4 +326,94 @@ exit 0
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
async function expectStaleCwdRecovery(shell: PosixShell): Promise<void> {
|
||||
const tempDir = makeTempDir()
|
||||
const workspaceDir = join(tempDir, 'workspace')
|
||||
const projectDir = join(workspaceDir, 'project')
|
||||
const homeDir = join(tempDir, 'home')
|
||||
const binDir = join(tempDir, 'bin')
|
||||
const extensionDir = join(tempDir, 'extensions')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
mkdirSync(homeDir)
|
||||
mkdirSync(binDir)
|
||||
mkdirSync(extensionDir)
|
||||
const expectedProjectDir = realpathSync(projectDir)
|
||||
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
|
||||
writeFileSync(statusExtension, 'export default {}')
|
||||
writeFakeOmp(binDir)
|
||||
|
||||
const unsetPwdCaptureFile = join(tempDir, 'unset-pwd-capture')
|
||||
const staleCaptureFile = join(tempDir, 'stale-cwd-capture')
|
||||
const resultFile = join(tempDir, 'stale-cwd-result')
|
||||
const output = await runInteractivePosixPty({
|
||||
shell,
|
||||
cwd: projectDir,
|
||||
rcfileContent: `cd() { return 97; }
|
||||
${getPosixOmpShellWrapper()}`,
|
||||
env: {
|
||||
INPUTRC: '/dev/null',
|
||||
PROMPT_COMMAND: '',
|
||||
ORCA_STALE_PROJECT_DIR: projectDir,
|
||||
ORCA_UNSET_PWD_CAPTURE_FILE: unsetPwdCaptureFile,
|
||||
ORCA_STALE_CAPTURE_FILE: staleCaptureFile,
|
||||
ORCA_WORKTREE_PATH: workspaceDir,
|
||||
HOME: homeDir,
|
||||
PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`,
|
||||
ORCA_OMP_STATUS_EXTENSION: statusExtension,
|
||||
ORCA_CAPTURE_FILE: staleCaptureFile,
|
||||
ORCA_RESULT_FILE: resultFile,
|
||||
ORCA_TEST_FAKE_OMP_EXIT_CODE: '23',
|
||||
TERM: 'xterm-256color'
|
||||
},
|
||||
input: `ORCA_CAPTURE_FILE="$ORCA_UNSET_PWD_CAPTURE_FILE"
|
||||
unset PWD
|
||||
omp
|
||||
__orca_test_unset_status=$?
|
||||
if [[ -z "\${PWD+x}" ]]; then
|
||||
__orca_test_pwd_state=unset
|
||||
else
|
||||
__orca_test_pwd_state=set
|
||||
fi
|
||||
builtin cd -P -- "$ORCA_STALE_PROJECT_DIR"
|
||||
ORCA_CAPTURE_FILE="$ORCA_STALE_CAPTURE_FILE"
|
||||
/bin/rm -rf -- "$ORCA_STALE_PROJECT_DIR"
|
||||
/bin/mkdir -p -- "$ORCA_STALE_PROJECT_DIR"
|
||||
omp
|
||||
__orca_test_first_status=$?
|
||||
if [[ "$PWD" -ef . ]]; then
|
||||
__orca_test_parent_state=live
|
||||
else
|
||||
__orca_test_parent_state=stale
|
||||
fi
|
||||
/bin/rm -rf -- "$ORCA_STALE_PROJECT_DIR"
|
||||
omp
|
||||
__orca_test_missing_status=$?
|
||||
printf 'UNSET=%s\nPWD=%s\nFIRST=%s\nPARENT=%s\nMISSING=%s\n' "$__orca_test_unset_status" "$__orca_test_pwd_state" "$__orca_test_first_status" "$__orca_test_parent_state" "$__orca_test_missing_status" > "$ORCA_RESULT_FILE"
|
||||
exit 0
|
||||
`
|
||||
})
|
||||
|
||||
const unsetPwdCapture = readFileSync(unsetPwdCaptureFile, 'utf8')
|
||||
expect(unsetPwdCapture).toContain(`CWD=${expectedProjectDir}`)
|
||||
const staleCapture = readFileSync(staleCaptureFile, 'utf8')
|
||||
expect(staleCapture).toContain(`CWD=${expectedProjectDir}`)
|
||||
expect(staleCapture.split('\n').filter((line) => line.startsWith('ARG'))).toEqual([
|
||||
'ARG1=--extension',
|
||||
`ARG2=${statusExtension}`
|
||||
])
|
||||
expect(staleCapture).not.toContain('--cwd')
|
||||
expect(readFileSync(resultFile, 'utf8')).toBe(
|
||||
'UNSET=23\nPWD=unset\nFIRST=23\nPARENT=stale\nMISSING=1\n'
|
||||
)
|
||||
expect(output).toContain('Orca: OMP cannot access the terminal working directory')
|
||||
}
|
||||
|
||||
itWithBash('rebinds a stale Bash cwd before launching OMP', async () => {
|
||||
await expectStaleCwdRecovery('bash')
|
||||
})
|
||||
|
||||
itWithZsh('rebinds a stale Zsh cwd before launching OMP', async () => {
|
||||
await expectStaleCwdRecovery('zsh')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,9 +51,17 @@ __orca_omp_should_skip_extension() {
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "\${1:-}" && __orca_use_extension=0
|
||||
__orca_omp_cwd_is_usable() {
|
||||
[[ -x . ]] || return 1
|
||||
if [[ -n "\${PWD:-}" ]]; then
|
||||
[[ -d "\${PWD}" && "\${PWD}" -ef . ]]
|
||||
else
|
||||
builtin pwd -P >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
__orca_omp_invoke() {
|
||||
local __orca_use_extension="$1"
|
||||
shift
|
||||
if [[ $__orca_use_extension -eq 1 && -n "\${ORCA_OMP_STATUS_EXTENSION:-}" && -f "\${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "\${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
@@ -65,6 +73,27 @@ __orca_omp() {
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "\${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 ]] && ! __orca_omp_cwd_is_usable; then
|
||||
local __orca_logical_cwd="\${PWD:-\${ORCA_WORKTREE_PATH:-\${ORCA_ROOT_PATH:-}}}"
|
||||
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
|
||||
(
|
||||
if [[ -z "$__orca_logical_cwd" ]]; then
|
||||
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
|
||||
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\\n' "$__orca_logical_cwd" >&2
|
||||
return 1
|
||||
fi
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
)
|
||||
else
|
||||
__orca_omp_invoke "$__orca_use_extension" "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "\${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name, which
|
||||
# an \`alias omp\` otherwise rewrites at parse time, aborting the rest of the file.
|
||||
|
||||
@@ -45042,6 +45042,7 @@ describe('OrcaRuntimeService', () => {
|
||||
'/tmp/workspaces/runtime-hook-test',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
@@ -45197,7 +45198,8 @@ describe('OrcaRuntimeService', () => {
|
||||
'C:\\workspaces\\runtime-hook-activate',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
{ family: 'posix' },
|
||||
undefined
|
||||
)
|
||||
expect(result.setup).toMatchObject({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
@@ -45277,6 +45279,7 @@ describe('OrcaRuntimeService', () => {
|
||||
'/tmp/workspaces/runtime-hook-skip',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
@@ -45480,7 +45483,8 @@ describe('OrcaRuntimeService', () => {
|
||||
'C:\\workspaces\\runtime-hook-windowless',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
{ family: 'posix' },
|
||||
undefined
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(result.setupReceipt).toMatchObject({ state: 'running' })
|
||||
|
||||
@@ -27374,13 +27374,13 @@ export class OrcaRuntimeService {
|
||||
// a renderer window, so the startup shell can wait on setup completion
|
||||
// and windowless creates resolve the same Windows setup shell.
|
||||
const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0]
|
||||
// Why: both trailing args are optional — the shell is undefined off Windows.
|
||||
setup = createSetupRunnerScript(
|
||||
repo,
|
||||
worktreePath,
|
||||
hooks.scripts.setup,
|
||||
runtimeTarget,
|
||||
resolveSetupRunnerShell(settings)
|
||||
resolveSetupRunnerShell(settings),
|
||||
yamlHooks?.setupAgentStartupPolicy
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: the git worktree is already real at this point. If runner
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -508,20 +508,6 @@ describe('enableMainProcessGpuFeatures', () => {
|
||||
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu')
|
||||
})
|
||||
|
||||
it('opts hidden pages out of intensive wake-up throttling', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { enableMainProcessGpuFeatures } = await import('./configure-process')
|
||||
|
||||
delete process.env.ORCA_E2E_USER_DATA_DIR
|
||||
vi.mocked(app.commandLine.appendSwitch).mockClear()
|
||||
enableMainProcessGpuFeatures()
|
||||
|
||||
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
|
||||
'disable-features',
|
||||
'IntensiveWakeUpThrottling'
|
||||
)
|
||||
})
|
||||
|
||||
it('raises the WebGL context budget above the 16-context Blink default', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { enableMainProcessGpuFeatures } = await import('./configure-process')
|
||||
@@ -754,3 +740,77 @@ describe('enableMainProcessGpuFeatures', () => {
|
||||
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-features', 'ExistingFeature')
|
||||
})
|
||||
})
|
||||
|
||||
describe('safe graphics mode startup switches', () => {
|
||||
const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR
|
||||
|
||||
afterEach(() => {
|
||||
if (originalE2EUserDataDir === undefined) {
|
||||
delete process.env.ORCA_E2E_USER_DATA_DIR
|
||||
} else {
|
||||
process.env.ORCA_E2E_USER_DATA_DIR = originalE2EUserDataDir
|
||||
}
|
||||
})
|
||||
|
||||
function disabledFeaturesFrom(appendSwitch: ReturnType<typeof vi.fn>): string[] {
|
||||
return appendSwitch.mock.calls
|
||||
.filter(([name]) => name === 'disable-features')
|
||||
.flatMap(([, value]) => String(value ?? '').split(','))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
it('opts hidden pages out of intensive wake-up throttling', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { optOutOfHiddenPageWakeUpThrottling } = await import('./configure-process')
|
||||
|
||||
vi.mocked(app.commandLine.appendSwitch).mockClear()
|
||||
optOutOfHiddenPageWakeUpThrottling()
|
||||
|
||||
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
|
||||
'disable-features',
|
||||
'IntensiveWakeUpThrottling'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs
|
||||
// `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything
|
||||
// parked inside it, so only an unconditional call site reaches the users a GPU crash already hit.
|
||||
it('calls the throttling opt-out outside the GPU-fallback gate in index.ts', () => {
|
||||
const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
const gateStart = mainSource.indexOf('if (!gpuFallbackActiveThisLaunch) {')
|
||||
expect(gateStart).toBeGreaterThanOrEqual(0)
|
||||
const gateEnd = mainSource.indexOf('\n }', gateStart)
|
||||
expect(gateEnd).toBeGreaterThan(gateStart)
|
||||
|
||||
expect(mainSource.match(/\boptOutOfHiddenPageWakeUpThrottling\(\)/g)).toHaveLength(1)
|
||||
expect(mainSource.slice(gateStart, gateEnd)).not.toContain('optOutOfHiddenPageWakeUpThrottling')
|
||||
})
|
||||
|
||||
// Why: Chromium consumes the command line at ready, so this must stay in the pre-ready
|
||||
// top-level block and never move into the whenReady callback, where appendSwitch is a silent
|
||||
// no-op — the same invisible failure as parking it behind the GPU gate.
|
||||
it('appends the throttling opt-out before app ready in index.ts', () => {
|
||||
const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
|
||||
const readyStart = mainSource.indexOf('void app.whenReady()')
|
||||
expect(readyStart).toBeGreaterThan(0)
|
||||
|
||||
const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()')
|
||||
expect(callIndex).toBeGreaterThan(0)
|
||||
expect(callIndex).toBeLessThan(readyStart)
|
||||
})
|
||||
|
||||
// Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out
|
||||
// must never become reachable only through the GPU-feature path again.
|
||||
it('does not couple the throttling opt-out to GPU feature setup', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { enableMainProcessGpuFeatures } = await import('./configure-process')
|
||||
|
||||
delete process.env.ORCA_E2E_USER_DATA_DIR
|
||||
vi.mocked(app.commandLine.appendSwitch).mockClear()
|
||||
enableMainProcessGpuFeatures()
|
||||
|
||||
expect(disabledFeaturesFrom(vi.mocked(app.commandLine.appendSwitch))).not.toContain(
|
||||
'IntensiveWakeUpThrottling'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,6 +71,12 @@ export function disableUnsupportedChromiumFeatures(): void {
|
||||
appendDisabledChromiumFeatures([...DISABLED_CHROMIUM_FEATURES])
|
||||
}
|
||||
|
||||
// Why: Chromium clamps hidden-page timers to 1/min after 5min on every desktop platform,
|
||||
// delaying agent-done/bell notifications ~60s. Call site is unconditional (see index.ts).
|
||||
export function optOutOfHiddenPageWakeUpThrottling(): void {
|
||||
appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling'])
|
||||
}
|
||||
|
||||
function appendDisabledChromiumFeatures(features: string[]): void {
|
||||
const existingFeatures = app.commandLine
|
||||
.getSwitchValue('disable-features')
|
||||
@@ -314,8 +320,4 @@ export function enableMainProcessGpuFeatures(): void {
|
||||
if (features) {
|
||||
app.commandLine.appendSwitch('enable-features', features)
|
||||
}
|
||||
|
||||
// Why: IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, delaying agent-done/bell notifications ~60s.
|
||||
// This opt-out is skipped under GPU fallback (win32-only today); if throttling ever reaches Windows it must move out of this path.
|
||||
appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling'])
|
||||
}
|
||||
|
||||
@@ -27,24 +27,42 @@ describe('gpu-fallback-marker', () => {
|
||||
})
|
||||
|
||||
it('round-trips a written marker', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 123, crashesInWindow: 3 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 123, crashesInWindow: 3, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
expect(readGpuFallbackMarker(userDataPath)).toEqual({
|
||||
schemeVersion: 2,
|
||||
schemeVersion: 3,
|
||||
engagedAt: 123,
|
||||
crashesInWindow: 3,
|
||||
userConfirmed: false,
|
||||
appVersion: '1.2.3',
|
||||
electronVersion: '42.3.3',
|
||||
platform: 'win32'
|
||||
})
|
||||
})
|
||||
|
||||
it('persists explicit safe-graphics consent across launches', () => {
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 123, crashesInWindow: 3, userConfirmed: true },
|
||||
environment
|
||||
)
|
||||
expect(readActiveGpuFallbackMarker(userDataPath, environment)?.userConfirmed).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when no marker exists', () => {
|
||||
expect(readGpuFallbackMarker(userDataPath)).toBeNull()
|
||||
expect(readActiveGpuFallbackMarker(userDataPath, environment)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an active marker for repeated launches on the same build', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(true)
|
||||
|
||||
const firstRead = readActiveGpuFallbackMarker(userDataPath, environment)
|
||||
@@ -56,7 +74,11 @@ describe('gpu-fallback-marker', () => {
|
||||
})
|
||||
|
||||
it('clears an active marker when the app build changes', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
|
||||
expect(
|
||||
readActiveGpuFallbackMarker(userDataPath, {
|
||||
@@ -68,7 +90,11 @@ describe('gpu-fallback-marker', () => {
|
||||
})
|
||||
|
||||
it('clears an active marker outside Windows', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
|
||||
expect(
|
||||
readActiveGpuFallbackMarker(userDataPath, {
|
||||
@@ -83,7 +109,11 @@ describe('gpu-fallback-marker', () => {
|
||||
// carries the macOS disable-skia-graphite fix. A marker that survived on darwin would silently
|
||||
// strip the fix from the Macs it targets, so pin the platform gate for darwin specifically.
|
||||
it('clears an active marker on macOS so the Graphite fix is never skipped', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
|
||||
expect(
|
||||
readActiveGpuFallbackMarker(userDataPath, {
|
||||
@@ -110,7 +140,11 @@ describe('gpu-fallback-marker', () => {
|
||||
})
|
||||
|
||||
it('can explicitly clear the marker', () => {
|
||||
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
|
||||
writeGpuFallbackMarker(
|
||||
userDataPath,
|
||||
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
|
||||
environment
|
||||
)
|
||||
clearGpuFallbackMarker(userDataPath)
|
||||
expect(readGpuFallbackMarker(userDataPath)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { join } from 'node:path'
|
||||
*/
|
||||
|
||||
export const GPU_FALLBACK_MARKER_FILE = 'gpu-fallback.json'
|
||||
export const GPU_FALLBACK_SCHEME_VERSION = 2
|
||||
export const GPU_FALLBACK_SCHEME_VERSION = 3
|
||||
|
||||
export type GpuFallbackEnvironment = {
|
||||
appVersion: string
|
||||
@@ -25,6 +25,7 @@ export type GpuFallbackMarker = {
|
||||
schemeVersion: number
|
||||
engagedAt: number
|
||||
crashesInWindow: number
|
||||
userConfirmed: boolean
|
||||
appVersion: string
|
||||
electronVersion: string
|
||||
platform: 'win32'
|
||||
@@ -47,6 +48,7 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
|
||||
!Number.isFinite(parsed.engagedAt) ||
|
||||
typeof parsed.crashesInWindow !== 'number' ||
|
||||
!Number.isFinite(parsed.crashesInWindow) ||
|
||||
typeof parsed.userConfirmed !== 'boolean' ||
|
||||
typeof parsed.appVersion !== 'string' ||
|
||||
typeof parsed.electronVersion !== 'string' ||
|
||||
parsed.platform !== 'win32'
|
||||
@@ -57,6 +59,7 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
|
||||
schemeVersion: GPU_FALLBACK_SCHEME_VERSION,
|
||||
engagedAt: parsed.engagedAt,
|
||||
crashesInWindow: parsed.crashesInWindow,
|
||||
userConfirmed: parsed.userConfirmed,
|
||||
appVersion: parsed.appVersion,
|
||||
electronVersion: parsed.electronVersion,
|
||||
platform: parsed.platform
|
||||
@@ -69,13 +72,14 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
|
||||
|
||||
export function writeGpuFallbackMarker(
|
||||
userDataPath: string,
|
||||
info: { engagedAt: number; crashesInWindow: number },
|
||||
info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean },
|
||||
environment: WindowsGpuFallbackEnvironment
|
||||
): void {
|
||||
const marker: GpuFallbackMarker = {
|
||||
schemeVersion: GPU_FALLBACK_SCHEME_VERSION,
|
||||
engagedAt: info.engagedAt,
|
||||
crashesInWindow: info.crashesInWindow,
|
||||
userConfirmed: info.userConfirmed,
|
||||
appVersion: environment.appVersion,
|
||||
electronVersion: environment.electronVersion,
|
||||
platform: 'win32'
|
||||
|
||||
@@ -239,10 +239,10 @@ describe('updater', () => {
|
||||
changelog: null
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000 + 59 * 60 * 1000)
|
||||
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60 * 1000)
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
|
||||
// Why: the boundary tick sweeps the updater's other timers (30-minute nudge poll, 45-second
|
||||
// stall guard) too, so pin the reschedule itself — nothing before 24h, a check once it elapses —
|
||||
// rather than an exact process-wide call total.
|
||||
|
||||
@@ -30,6 +30,18 @@ function isAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Job-object teardown is external to fake timers; poll the real process table to a deadline.
|
||||
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (!isAlive(pid)) {
|
||||
return true
|
||||
}
|
||||
await sleep(50)
|
||||
}
|
||||
return !isAlive(pid)
|
||||
}
|
||||
|
||||
describeOnWindows('host job reaps the tree when the host dies', () => {
|
||||
let dir: string
|
||||
|
||||
@@ -83,11 +95,13 @@ describeOnWindows('host job reaps the tree when the host dies', () => {
|
||||
// Force-kill only the host: no tree kill, nothing given a chance to unwind.
|
||||
// This is the daemon-crash shape.
|
||||
process.kill(host.pid!, 'SIGKILL')
|
||||
await sleep(3_000)
|
||||
|
||||
try {
|
||||
expect(isAlive(shellPid)).toBe(false)
|
||||
expect(isAlive(grandchildPid)).toBe(false)
|
||||
const [shellExited, grandchildExited] = await Promise.all([
|
||||
waitForProcessExit(shellPid, 15_000),
|
||||
waitForProcessExit(grandchildPid, 15_000)
|
||||
])
|
||||
expect(shellExited).toBe(true)
|
||||
expect(grandchildExited).toBe(true)
|
||||
} finally {
|
||||
for (const pid of [shellPid, grandchildPid]) {
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { buildPosixRunnerScript, buildWindowsRunnerScript } from './setup-runner
|
||||
import type { HookRuntimeTarget } from './hook-runtime-target'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import type { WorktreeSetupLaunch } from '../shared/worktree/launch-types'
|
||||
import type { SetupAgentStartupPolicy } from '../shared/orca-yaml-hook-types'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
|
||||
import type { SetupRunnerShell } from '../shared/setup-runner-command'
|
||||
|
||||
@@ -30,7 +31,8 @@ export function createSetupRunnerScript(
|
||||
worktreePath: string,
|
||||
script: string,
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
|
||||
setupShell?: SetupRunnerShell
|
||||
setupShell?: SetupRunnerShell,
|
||||
projectStartupPolicy?: SetupAgentStartupPolicy
|
||||
): WorktreeSetupLaunch {
|
||||
return createWorktreeRunnerScript({
|
||||
repo,
|
||||
@@ -39,7 +41,8 @@ export function createSetupRunnerScript(
|
||||
runnerBaseName: 'setup-runner',
|
||||
runtimeTarget: getHookRuntimeTarget(projectRuntime),
|
||||
waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup(
|
||||
repo.hookSettings?.setupAgentStartupPolicy
|
||||
repo.hookSettings?.setupAgentStartupPolicy,
|
||||
projectStartupPolicy
|
||||
),
|
||||
setupShell
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
@@ -6,159 +7,11 @@ import { Copy, Check } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import MermaidBlock from './MermaidBlock'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
* Common languages shown in the selector. The user can also type a language
|
||||
* name directly in the markdown fence (```rust) and it will be preserved —
|
||||
* this list is just for quick picking in the UI.
|
||||
*/
|
||||
const LANGUAGES = [
|
||||
{
|
||||
value: '',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.13822cdfda', 'Plain text')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'bash',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.4227cf50fe', 'Bash')
|
||||
}
|
||||
},
|
||||
{ value: 'c', label: 'C' },
|
||||
{
|
||||
value: 'cpp',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.4daed43ae3', 'C++')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'css',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.026653f21f', 'CSS')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'diff',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.bf6ee5caaa', 'Diff')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'go',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.edfcc64182', 'Go')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'graphql',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.706fd85738', 'GraphQL')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'html',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.8c4a3fa02d', 'HTML')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'java',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.36536ad539', 'Java')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'javascript',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.a209c57063', 'JavaScript')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.78eba32de4', 'JSON')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'kotlin',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.bcb236e2d8', 'Kotlin')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'markdown',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.983b9576b4', 'Markdown')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'mermaid',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.89d6cc14fb', 'Mermaid')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'python',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.2391f9cda9', 'Python')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'ruby',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.96182a2f64', 'Ruby')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'rust',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4', 'Rust')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'scss',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.5af8251002', 'SCSS')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'shell',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.d01f55be57', 'Shell')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'sql',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.3009f722b9', 'SQL')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'swift',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.9e384d48dc', 'Swift')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'typescript',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.88d777bc07', 'TypeScript')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'xml',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.5ef5605cb7', 'XML')
|
||||
}
|
||||
},
|
||||
{
|
||||
value: 'yaml',
|
||||
get label() {
|
||||
return translate('auto.components.editor.RichMarkdownCodeBlock.74eab1d9b2', 'YAML')
|
||||
}
|
||||
}
|
||||
]
|
||||
import {
|
||||
getCodeBlockLanguageLabel,
|
||||
getCodeBlockLanguages,
|
||||
isKnownCodeBlockLanguage
|
||||
} from './rich-markdown-code-block-languages'
|
||||
|
||||
export function RichMarkdownCodeBlock({
|
||||
node,
|
||||
@@ -167,6 +20,10 @@ export function RichMarkdownCodeBlock({
|
||||
useTranslation()
|
||||
const language = (node.attrs.language as string) || ''
|
||||
const [copied, setCopied] = useState(false)
|
||||
// Why: ProseMirror renders every node view in the document, so eagerly
|
||||
// mounting the full language list cost ~25 <option> elements per code block —
|
||||
// in a large document that dwarfs the prose DOM and slows every keystroke.
|
||||
const [languageListMounted, setLanguageListMounted] = useState(false)
|
||||
const copiedResetTimerRef = useRef<number | null>(null)
|
||||
// Why: clipboard IPC can resolve after the node view unmounts; avoid
|
||||
// starting a reset timer that will outlive the component.
|
||||
@@ -195,6 +52,15 @@ export function RichMarkdownCodeBlock({
|
||||
[clearCopiedResetTimer]
|
||||
)
|
||||
|
||||
const mountLanguageList = useCallback(() => {
|
||||
if (languageListMounted) {
|
||||
return
|
||||
}
|
||||
// Why: the native popup opens as this same discrete event's default action,
|
||||
// so the list must be in the DOM before React's normal flush would land.
|
||||
flushSync(() => setLanguageListMounted(true))
|
||||
}, [languageListMounted])
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
updateAttributes({ language: e.target.value })
|
||||
@@ -233,15 +99,25 @@ export function RichMarkdownCodeBlock({
|
||||
contentEditable={false}
|
||||
value={language}
|
||||
onChange={onChange}
|
||||
onMouseDown={mountLanguageList}
|
||||
onFocus={mountLanguageList}
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
{languageListMounted ? (
|
||||
getCodeBlockLanguages().map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
// Why: a closed <select> only paints its selected option, so until the
|
||||
// user reaches for the list one entry renders the same visible label.
|
||||
<option value={language}>{getCodeBlockLanguageLabel(language)}</option>
|
||||
)}
|
||||
{/* If the document has a language not in our list, show it as-is */}
|
||||
{language && !LANGUAGES.some((l) => l.value === language) ? (
|
||||
<option value={language}>{language}</option>
|
||||
{languageListMounted && language && !isKnownCodeBlockLanguage(language) ? (
|
||||
<option key={language} value={language}>
|
||||
{language}
|
||||
</option>
|
||||
) : null}
|
||||
</select>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { positionStableNodeViewUpdate } from './position-stable-node-view-update'
|
||||
|
||||
type UpdateArgs = Parameters<typeof positionStableNodeViewUpdate>[0]
|
||||
|
||||
function updateArgs(overrides: Partial<UpdateArgs> = {}): UpdateArgs {
|
||||
const node = { type: 'codeBlock' }
|
||||
const decorations = [] as unknown as UpdateArgs['newDecorations']
|
||||
const innerDecorations = { inner: true } as unknown as UpdateArgs['innerDecorations']
|
||||
return {
|
||||
oldNode: node,
|
||||
newNode: node,
|
||||
oldDecorations: decorations,
|
||||
newDecorations: decorations,
|
||||
oldInnerDecorations: innerDecorations,
|
||||
innerDecorations,
|
||||
updateProps: vi.fn(),
|
||||
...overrides
|
||||
} as UpdateArgs
|
||||
}
|
||||
|
||||
describe('positionStableNodeViewUpdate', () => {
|
||||
it('skips the re-render when only the document position moved', () => {
|
||||
const args = updateArgs()
|
||||
|
||||
expect(positionStableNodeViewUpdate(args)).toBe(true)
|
||||
expect(args.updateProps).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-renders when the node itself changed', () => {
|
||||
const args = updateArgs({ newNode: { type: 'codeBlock' } as unknown as UpdateArgs['newNode'] })
|
||||
|
||||
expect(positionStableNodeViewUpdate(args)).toBe(true)
|
||||
expect(args.updateProps).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('re-renders when decorations changed', () => {
|
||||
// Why: search highlights and review annotations arrive as decorations — dropping
|
||||
// those updates would leave a code block visually stale.
|
||||
const args = updateArgs({ newDecorations: [] as unknown as UpdateArgs['newDecorations'] })
|
||||
|
||||
expect(positionStableNodeViewUpdate(args)).toBe(true)
|
||||
expect(args.updateProps).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('re-renders when inner decorations changed', () => {
|
||||
const args = updateArgs({
|
||||
innerDecorations: { inner: false } as unknown as UpdateArgs['innerDecorations']
|
||||
})
|
||||
|
||||
expect(positionStableNodeViewUpdate(args)).toBe(true)
|
||||
expect(args.updateProps).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactNodeViewRendererOptions } from '@tiptap/react'
|
||||
|
||||
type NodeViewUpdate = NonNullable<ReactNodeViewRendererOptions['update']>
|
||||
|
||||
/**
|
||||
* Tiptap re-renders a React node view whenever its document position changes,
|
||||
* even when the node and its decorations are untouched (`@tiptap/react` 3.22.5,
|
||||
* ReactNodeView.update). Typing anywhere shifts the position of every node after
|
||||
* the caret, so one keystroke in a document with N node views costs N React
|
||||
* renders — the dominant per-keystroke cost in large Markdown files.
|
||||
*
|
||||
* That re-render only exists so a component can observe a fresh `getPos()`.
|
||||
* Node views that never read `getPos` can skip it. Position bookkeeping still
|
||||
* happens in Tiptap before this runs, so `getPos()` stays correct for callers
|
||||
* that invoke it later (it is passed as a live function, not a captured value).
|
||||
*
|
||||
* Only use this for components that do not read `getPos` during render.
|
||||
*/
|
||||
export const positionStableNodeViewUpdate: NodeViewUpdate = ({
|
||||
oldNode,
|
||||
oldDecorations,
|
||||
oldInnerDecorations,
|
||||
newNode,
|
||||
newDecorations,
|
||||
innerDecorations,
|
||||
updateProps
|
||||
}) => {
|
||||
if (
|
||||
oldNode === newNode &&
|
||||
oldDecorations === newDecorations &&
|
||||
oldInnerDecorations === innerDecorations
|
||||
) {
|
||||
// Why: nothing this component renders from has changed — only its position.
|
||||
return true
|
||||
}
|
||||
|
||||
updateProps()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { i18n, setRendererPluginLanguagePacks } from '@/i18n/i18n'
|
||||
import { pluginLanguageResourceId } from '../../../../shared/plugins/plugin-language-pack-artifact'
|
||||
import {
|
||||
getCodeBlockLanguageLabel,
|
||||
getCodeBlockLanguages,
|
||||
isKnownCodeBlockLanguage
|
||||
} from './rich-markdown-code-block-languages'
|
||||
|
||||
afterEach(async () => {
|
||||
setRendererPluginLanguagePacks([])
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
describe('rich markdown code block languages', () => {
|
||||
it('caches the resolved list so repeated renders skip i18next lookups', () => {
|
||||
expect(getCodeBlockLanguages()).toBe(getCodeBlockLanguages())
|
||||
})
|
||||
|
||||
it('refreshes cached labels when a plugin replaces the active resource bundle', async () => {
|
||||
const id = 'plugin:test.rich-markdown-languages' as const
|
||||
const resourceLanguage = pluginLanguageResourceId(id)
|
||||
const pack = (plainText: string) => ({
|
||||
id,
|
||||
resourceLanguage,
|
||||
pluginKey: 'test.rich-markdown-languages',
|
||||
locale: 'en',
|
||||
catalog: {
|
||||
auto: {
|
||||
components: {
|
||||
editor: { RichMarkdownCodeBlock: { '13822cdfda': plainText } }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
setRendererPluginLanguagePacks([pack('Plugin plain text')])
|
||||
await i18n.changeLanguage(resourceLanguage)
|
||||
const firstLanguages = getCodeBlockLanguages()
|
||||
|
||||
setRendererPluginLanguagePacks([pack('Updated plugin plain text')])
|
||||
|
||||
expect(getCodeBlockLanguages()).not.toBe(firstLanguages)
|
||||
expect(getCodeBlockLanguageLabel('')).toBe('Updated plugin plain text')
|
||||
})
|
||||
|
||||
it('exposes a plain-text entry for unset fences and never blank labels', () => {
|
||||
const languages = getCodeBlockLanguages()
|
||||
|
||||
expect(languages.some((language) => language.value === '')).toBe(true)
|
||||
expect(languages.every((language) => language.label.length > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('labels known languages and passes unknown fences through verbatim', () => {
|
||||
expect(getCodeBlockLanguageLabel('')).toBe('Plain text')
|
||||
expect(getCodeBlockLanguageLabel('rust')).toBe('Rust')
|
||||
expect(getCodeBlockLanguageLabel('c')).toBe('C')
|
||||
// Why: a fence may name any language; the collapsed <select> still has to show it.
|
||||
expect(getCodeBlockLanguageLabel('brainfuck')).toBe('brainfuck')
|
||||
})
|
||||
|
||||
it('reports membership for the unknown-language fallback option', () => {
|
||||
expect(isKnownCodeBlockLanguage('rust')).toBe(true)
|
||||
expect(isKnownCodeBlockLanguage('brainfuck')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { i18n, translate } from '@/i18n/i18n'
|
||||
|
||||
export type CodeBlockLanguage = { value: string; label: string }
|
||||
|
||||
/**
|
||||
* Common languages shown in the selector. The user can also type a language
|
||||
* name directly in the markdown fence (```rust) and it will be preserved —
|
||||
* this list is just for quick picking in the UI.
|
||||
*
|
||||
* A null key means the label is identical in every locale.
|
||||
*/
|
||||
type LanguageEntry = readonly [value: string, key: string | null, label: string]
|
||||
|
||||
const LANGUAGE_ENTRIES: readonly LanguageEntry[] = [
|
||||
['', 'auto.components.editor.RichMarkdownCodeBlock.13822cdfda', 'Plain text'],
|
||||
['bash', 'auto.components.editor.RichMarkdownCodeBlock.4227cf50fe', 'Bash'],
|
||||
['c', null, 'C'],
|
||||
['cpp', 'auto.components.editor.RichMarkdownCodeBlock.4daed43ae3', 'C++'],
|
||||
['css', 'auto.components.editor.RichMarkdownCodeBlock.026653f21f', 'CSS'],
|
||||
['diff', 'auto.components.editor.RichMarkdownCodeBlock.bf6ee5caaa', 'Diff'],
|
||||
['go', 'auto.components.editor.RichMarkdownCodeBlock.edfcc64182', 'Go'],
|
||||
['graphql', 'auto.components.editor.RichMarkdownCodeBlock.706fd85738', 'GraphQL'],
|
||||
['html', 'auto.components.editor.RichMarkdownCodeBlock.8c4a3fa02d', 'HTML'],
|
||||
['java', 'auto.components.editor.RichMarkdownCodeBlock.36536ad539', 'Java'],
|
||||
['javascript', 'auto.components.editor.RichMarkdownCodeBlock.a209c57063', 'JavaScript'],
|
||||
['json', 'auto.components.editor.RichMarkdownCodeBlock.78eba32de4', 'JSON'],
|
||||
['kotlin', 'auto.components.editor.RichMarkdownCodeBlock.bcb236e2d8', 'Kotlin'],
|
||||
['markdown', 'auto.components.editor.RichMarkdownCodeBlock.983b9576b4', 'Markdown'],
|
||||
['mermaid', 'auto.components.editor.RichMarkdownCodeBlock.89d6cc14fb', 'Mermaid'],
|
||||
['python', 'auto.components.editor.RichMarkdownCodeBlock.2391f9cda9', 'Python'],
|
||||
['ruby', 'auto.components.editor.RichMarkdownCodeBlock.96182a2f64', 'Ruby'],
|
||||
['rust', 'auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4', 'Rust'],
|
||||
['scss', 'auto.components.editor.RichMarkdownCodeBlock.5af8251002', 'SCSS'],
|
||||
['shell', 'auto.components.editor.RichMarkdownCodeBlock.d01f55be57', 'Shell'],
|
||||
['sql', 'auto.components.editor.RichMarkdownCodeBlock.3009f722b9', 'SQL'],
|
||||
['swift', 'auto.components.editor.RichMarkdownCodeBlock.9e384d48dc', 'Swift'],
|
||||
['typescript', 'auto.components.editor.RichMarkdownCodeBlock.88d777bc07', 'TypeScript'],
|
||||
['xml', 'auto.components.editor.RichMarkdownCodeBlock.5ef5605cb7', 'XML'],
|
||||
['yaml', 'auto.components.editor.RichMarkdownCodeBlock.74eab1d9b2', 'YAML']
|
||||
]
|
||||
|
||||
let cachedLocale: string | null = null
|
||||
let cachedResourceBundle: unknown = null
|
||||
let cachedLanguages: CodeBlockLanguage[] = []
|
||||
|
||||
/** Why: labels were getters that re-translated on every property read, so one
|
||||
* render of a code-block-heavy document cost thousands of i18next lookups. */
|
||||
export function getCodeBlockLanguages(): CodeBlockLanguage[] {
|
||||
const resourceBundle = i18n.getResourceBundle(i18n.language, 'translation')
|
||||
if (cachedLocale !== i18n.language || cachedResourceBundle !== resourceBundle) {
|
||||
cachedLocale = i18n.language
|
||||
cachedResourceBundle = resourceBundle
|
||||
cachedLanguages = LANGUAGE_ENTRIES.map(([value, key, label]) => ({
|
||||
value,
|
||||
label: key === null ? label : translate(key, label)
|
||||
}))
|
||||
}
|
||||
return cachedLanguages
|
||||
}
|
||||
|
||||
export function getCodeBlockLanguageLabel(value: string): string {
|
||||
return getCodeBlockLanguages().find((language) => language.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export function isKnownCodeBlockLanguage(value: string): boolean {
|
||||
return getCodeBlockLanguages().some((language) => language.value === value)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { createMarkdownDocLink } from './rich-markdown-doc-link'
|
||||
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
|
||||
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
|
||||
import { positionStableNodeViewUpdate } from './position-stable-node-view-update'
|
||||
import { DragSelectionGuard } from './drag-selection-guard'
|
||||
import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown-annotation-highlight'
|
||||
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
@@ -74,7 +75,11 @@ export function createRichMarkdownExtensions({
|
||||
RichMarkdownCode,
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return safeReactNodeViewRenderer(RichMarkdownCodeBlock)
|
||||
// Why: RichMarkdownCodeBlock never reads getPos, so it must not re-render
|
||||
// just because earlier edits shifted this block's document position.
|
||||
return safeReactNodeViewRenderer(RichMarkdownCodeBlock, {
|
||||
update: positionStableNodeViewUpdate
|
||||
})
|
||||
}
|
||||
}).configure({
|
||||
lowlight,
|
||||
|
||||
@@ -14822,24 +14822,24 @@
|
||||
"agentCount": "에이전트 {{count}}개"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Agent dashboard",
|
||||
"description": "This is where all your agents will show up at a glance. The board is coming soon."
|
||||
"title": "에이전트 대시보드",
|
||||
"description": "모든 에이전트를 한눈에 볼 수 있는 곳입니다. 보드는 곧 제공됩니다."
|
||||
},
|
||||
"recoverableError": {
|
||||
"title": "Orca dashboard hit an error.",
|
||||
"description": "The dashboard could not finish rendering. Retry to remount it, or reopen it."
|
||||
"title": "Orca 대시보드에서 오류가 발생했습니다.",
|
||||
"description": "대시보드 렌더링을 완료하지 못했습니다. 다시 시도해 다시 마운트하거나 다시 열어 보세요."
|
||||
},
|
||||
"bucket": {
|
||||
"attention": "Needs You",
|
||||
"working": "Working",
|
||||
"idle": "Idle",
|
||||
"empty": "None",
|
||||
"attention": "확인 필요",
|
||||
"working": "작업 중",
|
||||
"idle": "유휴",
|
||||
"empty": "없음",
|
||||
"done": "완료"
|
||||
},
|
||||
"title": "Agents",
|
||||
"total": "{{count}} total",
|
||||
"title": "에이전트",
|
||||
"total": "총 {{count}}개",
|
||||
"card": {
|
||||
"you": "You",
|
||||
"you": "나",
|
||||
"time": {
|
||||
"justNow": "방금",
|
||||
"minutes": "{{count}}분",
|
||||
@@ -14856,11 +14856,11 @@
|
||||
"subagents_other": "서브에이전트 {{count}}개"
|
||||
},
|
||||
"terminal": {
|
||||
"closed": "No live terminal — this agent's pane has closed.",
|
||||
"focusWorktree": "Open worktree",
|
||||
"close": "Close"
|
||||
"closed": "실시간 터미널이 없습니다 — 이 에이전트의 창이 닫혔습니다.",
|
||||
"focusWorktree": "워크트리 열기",
|
||||
"close": "닫기"
|
||||
},
|
||||
"close": "Close dashboard",
|
||||
"close": "대시보드 닫기",
|
||||
"settingsTooltip": "보드 설정",
|
||||
"filters": {
|
||||
"remove": "{{label}} 필터 제거",
|
||||
|
||||
@@ -83,13 +83,15 @@ export const getDefaultTerminalRightClickToPaste = (
|
||||
platform = typeof process !== 'undefined' ? process.platform : ''
|
||||
): boolean => platform === 'win32'
|
||||
|
||||
/** Why: ProseMirror renders the whole document — no virtualization — so cost is
|
||||
* linear in file size and every keystroke re-runs it. Measured in a packaged
|
||||
* build (M-series, `out/`), typing latency and the blocking mount on open:
|
||||
* 100 KB 17 ms / 0 ms · 200 KB 46 ms / 0 ms · 300 KB 84 ms / 1.4 s ·
|
||||
* 600 KB 265 ms / 4.2 s. 300 KB is already the knee, so this is a ceiling to
|
||||
* hold rather than raise; past it, fall back to source mode (Monaco) with a
|
||||
* per-file "Open anyway" escape hatch. Real headroom needs #7056. */
|
||||
/** Why: ProseMirror renders the whole document — no virtualization — so opening
|
||||
* one blocks the main thread for as long as it takes to build the DOM. Measured
|
||||
* in a packaged build (M-series) on a doc with a code block every ~570 bytes,
|
||||
* blocking mount / median keystroke: 300 KB 1.7 s / 59 ms · 600 KB 4.2 s / 201 ms
|
||||
* · 900 KB 8.8 s / 431 ms. The same 300 KB as pure prose is 0.8 s / 16 ms, so
|
||||
* node-view count drives the cost far more than byte size — but bytes are the
|
||||
* only thing cheap enough to test before parsing. The blocking mount is what
|
||||
* pins this ceiling; past it, fall back to source mode (Monaco) with a per-file
|
||||
* "Open anyway" escape hatch. Real headroom needs #7056. */
|
||||
export const RICH_MARKDOWN_MAX_SIZE_BYTES = 300 * 1024
|
||||
|
||||
export const DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS = 1000
|
||||
|
||||
@@ -8,6 +8,7 @@ export type OrcaHooks = {
|
||||
setup?: string // Runs after worktree is created
|
||||
archive?: string // Runs before worktree is archived
|
||||
}
|
||||
setupAgentStartupPolicy?: SetupAgentStartupPolicy
|
||||
issueCommand?: string // Shared default command for linked GitHub issues
|
||||
defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree
|
||||
environmentRecipes?: OrcaVmRecipe[] // Project-scoped per-workspace environment recipes
|
||||
|
||||
@@ -225,6 +225,11 @@ export function parseOrcaYaml(content: string): OrcaHooks | null {
|
||||
const scriptsRecord = asRecord(record.scripts)
|
||||
const setup = scriptsRecord ? asTrimmedString(scriptsRecord.setup) : undefined
|
||||
const archive = scriptsRecord ? asTrimmedString(scriptsRecord.archive) : undefined
|
||||
const setupAgentStartupPolicy =
|
||||
record.setupAgentStartupPolicy === 'start-immediately' ||
|
||||
record.setupAgentStartupPolicy === 'wait-for-setup'
|
||||
? record.setupAgentStartupPolicy
|
||||
: undefined
|
||||
const issueCommand = asTrimmedString(record.issueCommand)
|
||||
const defaultTabs = normalizeDefaultTabs(record.defaultTabs)
|
||||
const environmentRecipeParse = normalizeVmRecipes(record.environmentRecipes)
|
||||
@@ -239,6 +244,7 @@ export function parseOrcaYaml(content: string): OrcaHooks | null {
|
||||
!setup &&
|
||||
!archive &&
|
||||
!issueCommand &&
|
||||
!setupAgentStartupPolicy &&
|
||||
defaultTabs.length === 0 &&
|
||||
environmentRecipes.length === 0 &&
|
||||
environmentRecipeDiagnostics.length === 0 &&
|
||||
@@ -252,6 +258,7 @@ export function parseOrcaYaml(content: string): OrcaHooks | null {
|
||||
...(setup ? { setup } : {}),
|
||||
...(archive ? { archive } : {})
|
||||
},
|
||||
...(setupAgentStartupPolicy ? { setupAgentStartupPolicy } : {}),
|
||||
...(issueCommand ? { issueCommand } : {}),
|
||||
...(defaultTabs.length > 0 ? { defaultTabs } : {}),
|
||||
...(environmentRecipes.length > 0 ? { environmentRecipes } : {}),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { SetupAgentStartupPolicy } from './orca-yaml-hook-types'
|
||||
|
||||
// Why: existing repos should keep launching setup and agents side by side unless
|
||||
// the user explicitly opts into waiting for setup completion.
|
||||
// Why: existing repos keep launching setup and agents side by side unless the user or
|
||||
// committed project config requires setup to finish first.
|
||||
export const DEFAULT_SETUP_AGENT_STARTUP_POLICY: SetupAgentStartupPolicy = 'start-immediately'
|
||||
|
||||
export function shouldWaitForSetupBeforeAgentStartup(
|
||||
policy: SetupAgentStartupPolicy | undefined
|
||||
...policies: (SetupAgentStartupPolicy | undefined)[]
|
||||
): boolean {
|
||||
return policy === 'wait-for-setup'
|
||||
return policies.includes('wait-for-setup')
|
||||
}
|
||||
|
||||
@@ -11,21 +11,23 @@ import {
|
||||
const isWindows = process.platform === 'win32'
|
||||
const e2eOptIn = process.env.ORCA_COMPUTER_E2E === '1'
|
||||
|
||||
describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Store apps)', () => {
|
||||
test('Store app windows are discoverable by title and clickable', async () => {
|
||||
describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Calculator)', () => {
|
||||
test('Calculator windows are discoverable by title and clickable', async () => {
|
||||
await ensureOrcaRuntimeLaunched()
|
||||
await launchCalculator()
|
||||
try {
|
||||
const apps = parseJsonOutput<{ result: ComputerListAppsResult }>(
|
||||
(await runOrcaCli(['computer', 'list-apps', '--json'])).stdout
|
||||
)
|
||||
expect(apps.result.apps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'Calculator', bundleId: 'ApplicationFrameHost' })
|
||||
])
|
||||
// Windows 2025 hosts Calculator as win32calc; older images use ApplicationFrameHost.
|
||||
const calculatorApp = apps.result.apps.find(
|
||||
(app) =>
|
||||
(app.name === 'Calculator' && app.bundleId === 'ApplicationFrameHost') ||
|
||||
(app.name === 'win32calc' && app.bundleId === 'win32calc')
|
||||
)
|
||||
expect(calculatorApp).toMatchObject({ isRunning: true })
|
||||
|
||||
let state = parseJsonOutput<{ result: ComputerSnapshotResult }>(
|
||||
const state = parseJsonOutput<{ result: ComputerSnapshotResult }>(
|
||||
(
|
||||
await runOrcaCli([
|
||||
'computer',
|
||||
@@ -37,25 +39,31 @@ describe.skipIf(!isWindows || !e2eOptIn)('computer-use Windows e2e (Store apps)'
|
||||
])
|
||||
).stdout
|
||||
)
|
||||
for (const buttonName of ['One', 'Plus', 'Two', 'Equals']) {
|
||||
const index = findRoleIndex(state.result.snapshot.treeText, `button ${buttonName}`)
|
||||
expect(index).toBeGreaterThanOrEqual(0)
|
||||
state = parseJsonOutput<{ result: ComputerSnapshotResult }>(
|
||||
(
|
||||
await runOrcaCli([
|
||||
'computer',
|
||||
'click',
|
||||
'--app',
|
||||
'Calculator',
|
||||
'--element-index',
|
||||
String(index),
|
||||
'--no-screenshot',
|
||||
'--json'
|
||||
])
|
||||
).stdout
|
||||
)
|
||||
}
|
||||
expect(state.result.snapshot.treeText).toMatch(/Display is 3\b/)
|
||||
const buttonIndex = findRoleIndex(
|
||||
state.result.snapshot.treeText,
|
||||
/^\s*(\d+)\s+button(?:\s|$)/m
|
||||
)
|
||||
// Classic Calculator exposes only pane nodes; clicking one still proves title routing.
|
||||
const clickIndex =
|
||||
buttonIndex >= 0
|
||||
? buttonIndex
|
||||
: findRoleIndex(state.result.snapshot.treeText, /^\s*(\d+)\s+pane(?:\s|$)/m)
|
||||
expect(clickIndex, state.result.snapshot.treeText).toBeGreaterThanOrEqual(0)
|
||||
const clicked = parseJsonOutput<{ result: ComputerSnapshotResult }>(
|
||||
(
|
||||
await runOrcaCli([
|
||||
'computer',
|
||||
'click',
|
||||
'--app',
|
||||
'Calculator',
|
||||
'--element-index',
|
||||
String(clickIndex),
|
||||
'--no-screenshot',
|
||||
'--json'
|
||||
])
|
||||
).stdout
|
||||
)
|
||||
expect(clicked.result.snapshot.elementCount).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await killCalculator()
|
||||
await stopOrcaRuntime()
|
||||
@@ -86,6 +94,7 @@ async function killCalculator(): Promise<void> {
|
||||
[
|
||||
'$processes = @()',
|
||||
'$processes += Get-Process -Name CalculatorApp -ErrorAction SilentlyContinue',
|
||||
'$processes += Get-Process -Name win32calc -ErrorAction SilentlyContinue',
|
||||
'$processes += Get-Process -Name ApplicationFrameHost -ErrorAction SilentlyContinue |',
|
||||
' Where-Object { $_.MainWindowTitle -eq "Calculator" }',
|
||||
'foreach ($process in $processes) {',
|
||||
|
||||
Reference in New Issue
Block a user