Merge origin/main into OrcaWin/win-edr-cmd-shim-resolution

This commit is contained in:
Orca Worker
2026-09-05 21:12:22 -07:00
144 changed files with 6015 additions and 495 deletions
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
openbox --sm-disable > /tmp/orca-e2e-window-manager.log 2>&1 &
wm_pid=$!
cleanup() {
kill "$wm_pid" 2>/dev/null || true
wait "$wm_pid" 2>/dev/null || true
}
trap cleanup EXIT
ready=false
for attempt in {1..100}; do
if xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | rg -q 'window id # 0x[1-9a-fA-F]'; then
ready=true
break
fi
if ! kill -0 "$wm_pid" 2>/dev/null; then
cat /tmp/orca-e2e-window-manager.log
exit 1
fi
sleep 0.1
done
if [ "$ready" != true ]; then
echo 'Window manager did not acquire the Xvfb root window' >&2
exit 1
fi
"$@"
+8 -2
View File
@@ -127,9 +127,12 @@ jobs:
esac
# Bare: a work-tree repo refuses to fetch over its own checked-out
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
# blobs — so this stays cheap next to the build it fronts.
# blobs — so this stays cheap next to the build it fronts. reftable
# because this repo has branches that differ only in casing, and the
# files backend cannot store both on a case-insensitive runner disk —
# it fails the entire fetch, not just the one ref.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare "$scratch"
git init -q --bare --ref-format=reftable "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
# Branch first to keep actions/checkout's old tie-break: bare
# rev-parse would prefer the tag when a branch shares its name.
@@ -157,6 +160,9 @@ jobs:
- name: Checkout the requested ref
uses: actions/checkout@v6
env:
# Full-history checkout must also preserve case-twin branch and tag names.
GIT_DEFAULT_REF_FORMAT: reftable
with:
# Why an input at all rather than just github.ref: the whole point is to
# build code that has not landed, and the workflow definition itself
+5 -4
View File
@@ -25,9 +25,10 @@ defaults:
working-directory: cloud
jobs:
# Public-repository hosted runners preserve Blacksmith allowance for macOS.
security:
name: Secret scan
runs-on: blacksmith-2vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
@@ -53,7 +54,7 @@ jobs:
# Compiles the workspace. No Postgres service: nothing here reaches a
# database, and the service container costs ~13s of startup.
build:
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -73,7 +74,7 @@ jobs:
# package it needs through the relay pretest hook, so it does not depend on
# `pnpm build` having run.
test:
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
services:
postgres:
image: postgres:16-alpine
@@ -107,7 +108,7 @@ jobs:
# Fork pull requests reach this job, so it never configures a backend, never plans, and never
# holds a credential. Only the relay root ships here; foundation and apps stay private.
terraform:
runs-on: blacksmith-2vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
+5 -2
View File
@@ -149,9 +149,12 @@ jobs:
fi
# Reachability is the trust test: GitHub serves PR-only commits by SHA,
# so resolving the object is not proof a branch or tag of this repo
# reaches it. Bare + tree:0 keeps this to the commit graph.
# reaches it. Bare + tree:0 keeps this to the commit graph; reftable
# because branches that differ only in casing cannot both be stored by
# the files backend on a case-insensitive runner disk, which fails the
# entire fetch rather than the one ref.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare "$scratch"
git init -q --bare --ref-format=reftable "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
if ! git -C "$scratch" rev-parse --verify --quiet "$REQUESTED_SHA^{commit}" >/dev/null; then
echo "::error::Commit $REQUESTED_SHA is not in stablyai/orca."
+12 -8
View File
@@ -27,6 +27,10 @@ on:
description: Ref to check out (defaults to the workflow ref)
required: false
type: string
test_files:
description: JSON array of specs to run; empty runs the full suite
required: false
type: string
schedule:
# Why: GitHub cron uses UTC; these slots map to 10am and 3pm
# America/Phoenix for the default-branch E2E run.
@@ -146,7 +150,7 @@ jobs:
# Native cache misses need the compiler, Electron needs Xvfb, and paired
# Quick Open needs ripgrep. Install them in one apt transaction per shard.
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils
- uses: ./.github/actions/install-node-dependencies
with:
@@ -167,7 +171,7 @@ jobs:
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
# launches but never creates a BrowserWindow.
- name: Run E2E tests (${{ matrix.shard_name }})
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }}
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }}
# Why: Playwright retains traces/screenshots only on failure. Uploading
# them as an artifact makes post-mortem debugging on CI possible without
@@ -201,7 +205,7 @@ jobs:
# unbounded inventory fallback; the paired fixture exercises that real boundary.
# Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this
# lane now receives those specs from pr.yml's SSH source mapping.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
- uses: ./.github/actions/install-node-dependencies
with:
@@ -241,7 +245,7 @@ jobs:
if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then
E2E_PROJECT_ARGS+=(--project=electron-headful)
fi
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env "${E2E_ENV[@]}" \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
- name: Upload Playwright traces
@@ -278,7 +282,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
- uses: ./.github/actions/install-node-dependencies
with:
@@ -293,7 +297,7 @@ jobs:
# Why: this is the release-path proof that the deployed Linux relay keeps
# its PTY and explorer live across a real watcher SIGSEGV.
- 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
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh 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
@@ -310,7 +314,7 @@ jobs:
# 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
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh 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()
@@ -326,7 +330,7 @@ jobs:
# legible as an SSH-named failure.
- name: Run remaining Docker SSH E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
- name: Keep remaining-ssh-docker traces
if: always()
+3 -2
View File
@@ -93,12 +93,13 @@ jobs:
NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)"
echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED"
if [ "$TEST_FILES_JSON" != '[]' ]; then
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow)"
if [ "$SHOULD_RUN" = true ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Changed E2E specs: $TEST_FILES_JSON"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "No changed E2E specs"
echo "No specs requiring the reusable E2E workflow"
fi
static_analysis:
+13 -10
View File
@@ -858,16 +858,17 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/setup@v2
with:
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: Linux terminal golden E2E uses the same native install path as
# release CI, which needs pnpm to bypass its non-executable gyp_main.py.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
@@ -1074,16 +1075,17 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/setup@v2
with:
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: keep the non-blocking evidence lane on the same Linux native
# install path as the blocking golden and release build jobs.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
@@ -1716,6 +1718,7 @@ jobs:
with:
name: orca-windows-unsigned-${{ needs.cut.outputs.tag }}
path: dist/orca-windows-setup.exe
compression-level: 0
if-no-files-found: error
# Why: SignPath Foundation production certificates require manual review,
@@ -0,0 +1,38 @@
name: Release ref validation
on:
pull_request:
paths:
- '.github/workflows/adhoc-mac-build.yml'
- '.github/workflows/dev-channel-win-build.yml'
- '.github/workflows/release-ref-validation.yml'
- 'config/scripts/workflow-ref-reachability.test.mjs'
- 'config/scripts/workflow-ref-mirror-case-safety.test.mjs'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-ref-validation-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
validate:
strategy:
fail-fast: false
matrix:
os: [macos-15, windows-2022]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Verify case-twin refs and release trust boundary
run: >-
pnpm exec vitest run --config config/vitest.config.ts
config/scripts/workflow-ref-reachability.test.mjs
config/scripts/workflow-ref-mirror-case-safety.test.mjs
config/scripts/dev-channel-windows-workflow-contract.test.mjs
@@ -45,7 +45,9 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
# Historical skill snapshots need tags, but only their blobs are read.
fetch-depth: 0
filter: blob:none
persist-credentials: false
- uses: actions/setup-node@v6
with:
+2 -16
View File
@@ -38,23 +38,9 @@ jobs:
xfwm4
xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
- uses: ./.github/actions/install-node-dependencies
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/setup@v2
with:
install: false
- name: Use external node-gyp to avoid pnpm bundled copy
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"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Build Electron app for E2E
run: pnpm exec electron-vite build --mode e2e
+6 -5
View File
@@ -67,16 +67,17 @@ jobs:
- name: Install native build tools and xvfb
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/setup@v2
with:
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this scheduled/manual workflow uses the same native install path as
# PR and E2E CI, which needs pnpm to bypass its bundled gyp_main.py.
- name: Use external node-gyp to avoid pnpm's bundled copy
@@ -215,6 +215,7 @@ jobs:
with:
name: orca-windows-installer-unsigned-${{ github.run_id }}
path: dist/orca-windows-setup.exe
compression-level: 0
if-no-files-found: error
- name: Submit Windows installer signing request
+1
View File
@@ -111,6 +111,7 @@ docs/**
!docs/reference/orcad-operations.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-cmd-shim-resolution.md
!docs/reference/windows-daemon-host-relocation.md
!docs/reference/windows-edr-posture.md
!docs/reference/windows-process-enumeration.md
!docs/reference/wsl-runner-verification.md
+7
View File
@@ -4,6 +4,12 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
## Electron UI Validation
Always run tests and agent-launched apps in the background with `ORCA_BACKGROUND_LAUNCH=1`.
Never steal monitor focus or reveal test windows: no `show()`, `showInactive()`, `bringToFront()`,
`app.focus()`, or OS activation. Use CDP screenshots of hidden renderers. Keep native-focus and
visible-window tests paused on the user's desktop; run them on an isolated display or CI.
Rebuild modified launch-policy code before running an app; stale build wrappers are not safe.
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
# Style
@@ -49,6 +55,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one.
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
- **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md).
- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them.
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
+35 -9
View File
@@ -49,22 +49,48 @@
; ---------------------------------------------------------------------------
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
; Why: the daemon host is deliberately copied OUT of the install dir into
; %LOCALAPPDATA%\Orca\daemon-host so that app UPDATES cannot kill it —
; electron-builder's kill sweep selects processes whose image path is under
; $INSTDIR, and that relocation is what keeps terminals alive across updates.
; The same design means a normal uninstall's process sweep and file removal both
; miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
; The LOCALAPPDATA folder name must stay in sync with LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts. See
; docs/reference/windows-daemon-host-relocation.md.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
Push $0
Push $1
Push $2
; The host exe is a verbatim copy of the app exe, so the app's own image name
; reaches it; the second name covers hosts left by builds that renamed the copy.
; Filtered to the current user like upstream's per-user KILL_PROCESS, so an
; elevated machine-wide uninstall cannot reach another logged-on user's session.
; NSIS expands USERNAME itself: routing through cmd.exe only to get %USERNAME%
; would add two interpreter spawns to the uninstall path for nothing.
ReadEnvStr $1 USERNAME
${if} $1 == ""
; Measured: taskkill rejects an empty filter value outright ("The search filter
; cannot be recognized") and kills nothing, so with no USERNAME to scope by,
; kill unfiltered rather than not at all. USERNAME is set in every session an
; uninstaller runs in, so this is a backstop, not the expected path.
StrCpy $2 ""
${else}
StrCpy $2 '/FI "USERNAME eq $1"'
${endIf}
nsExec::Exec 'taskkill /F /IM "${APP_EXECUTABLE_FILENAME}" $2'
Pop $0
nsExec::Exec 'taskkill /F /IM "orca-terminal-daemon.exe" $2'
Pop $0
Pop $2
Pop $1
Pop $0
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { stripTypeScriptTypes } from 'node:module'
import { performance } from 'node:perf_hooks'
// Run from the worktree root: node config/scripts/benchmark-browser-tunnel-framing.mjs [base-ref]
const path = 'src/shared/browser-network-tunnel-stream-framing.ts'
const baselineRef = process.argv[2] ?? 'HEAD'
const beforeSource = execFileSync('git', ['show', `${baselineRef}:${path}`], {
encoding: 'utf8'
})
const afterSource = readFileSync(path, 'utf8')
const load = (source) =>
import(
`data:text/javascript;base64,${Buffer.from(
stripTypeScriptTypes(source, { mode: 'transform' })
).toString('base64')}`
)
const before = await load(beforeSource)
const after = await load(afterSource)
function measure(module, chunks, payload, repetitions) {
let frameCount = 0
let lastFrame
const onFrame = (frame) => {
frameCount++
lastFrame = frame
}
const onError = (error) => {
throw error
}
const run = () => {
const decoder = new module.BrowserNetworkTunnelStreamFrameDecoder(onFrame, onError)
for (const chunk of chunks) {
decoder.feed(chunk)
}
}
run()
assert.deepEqual(lastFrame, payload)
const samples = []
for (let sample = 0; sample < 5; sample++) {
const start = performance.now()
for (let iteration = 0; iteration < repetitions; iteration++) {
run()
}
samples.push((performance.now() - start) / repetitions)
}
assert.equal(frameCount, 1 + 5 * repetitions)
return samples.sort((a, b) => a - b)[2]
}
function countCopies(module, chunks) {
const originalSet = Uint8Array.prototype.set
const originalSlice = Uint8Array.prototype.slice
let copied = 0
Uint8Array.prototype.set = function (source, offset) {
copied += source.length
return originalSet.call(this, source, offset)
}
Uint8Array.prototype.slice = function (...args) {
const result = originalSlice.apply(this, args)
copied += result.length
return result
}
try {
const decoder = new module.BrowserNetworkTunnelStreamFrameDecoder(
() => {},
(error) => {
throw error
}
)
for (const chunk of chunks) {
decoder.feed(chunk)
}
} finally {
Uint8Array.prototype.set = originalSet
Uint8Array.prototype.slice = originalSlice
}
return copied
}
const rows = []
for (const [payloadBytes, chunkBytes, repetitions] of [
[1, 5, 10000],
[64 * 1024, 65540, 1000],
[64 * 1024, 4096, 100],
[64 * 1024, 256, 25],
[64 * 1024, 16, 5],
[64 * 1024, 1, 1]
]) {
const payload = Uint8Array.from({ length: payloadBytes }, (_, index) => index % 251)
const encoded = before.encodeBrowserNetworkTunnelStreamFrame(payload)
const chunks = []
for (let offset = 0; offset < encoded.length; offset += chunkBytes) {
chunks.push(encoded.subarray(offset, offset + chunkBytes))
}
const beforeMs = measure(before, chunks, payload, repetitions)
const afterMs = measure(after, chunks, payload, repetitions)
rows.push({
payloadBytes,
chunkBytes,
beforeMs: +beforeMs.toFixed(6),
afterMs: +afterMs.toFixed(6),
speedup: +(beforeMs / afterMs).toFixed(2),
beforeCopiedBytes: countCopies(before, chunks),
afterCopiedBytes: countCopies(after, chunks)
})
}
console.log(JSON.stringify({ node: process.version, baselineRef, rows }, null, 2))
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { existsSync, realpathSync } from 'node:fs'
import { delimiter, join, resolve } from 'node:path'
// Emit each revision with tsc -p config/tsconfig.cli.json --outDir <dir> --composite false --incremental false.
// Run: node config/scripts/benchmark-cli-error-imports.mjs <before-dir> <after-dir>
const [beforeDir, afterDir] = process.argv.slice(2)
assert.ok(beforeDir && afterDir, 'Pass distinct before and after TypeScript output directories.')
assert.notEqual(
realpathSync(beforeDir),
realpathSync(afterDir),
'Do not compare a build to itself.'
)
const entries = {
before: join(resolve(beforeDir), 'cli', 'index.js'),
after: join(resolve(afterDir), 'cli', 'index.js')
}
for (const entry of Object.values(entries)) {
assert.ok(existsSync(entry), `Missing emitted CLI: ${entry}`)
}
const { runProcessSync } = createRequire(import.meta.url)(
join(resolve(afterDir), 'shared', 'child-process', 'run-process.js')
)
const child = String.raw`
const { performance } = require('node:perf_hooks')
const { writeSync } = require('node:fs')
const { createHash } = require('node:crypto')
const { basename } = require('node:path')
let stdout = '', stderr = ''
process.stdout.write = (text) => { stdout += text; return true }
process.stderr.write = (text) => { stderr += text; return true }
const started = performance.now()
const cli = require(process.argv[1])
const importMs = performance.now() - started
cli.main(JSON.parse(process.argv[2])).then(() => {
const totalMs = performance.now() - started
const modules = Object.keys(require.cache)
writeSync(1, JSON.stringify({
importMs, totalMs, modules: modules.length,
featureFormatters: modules.filter((file) => ['browser', 'terminal', 'project', 'automation', 'workspace', 'computer'].some((name) => basename(file) === name + '-format.js')),
stdout: createHash('sha256').update(stdout).digest('hex'),
stderr: createHash('sha256').update(stderr).digest('hex'),
exitCode: process.exitCode || 0
}))
process.exitCode = 0
}).catch((error) => { writeSync(2, String(error)); process.exitCode = 1 })
`
const cases = [
['--help'],
['help', 'terminal', 'read'],
['does-not-exist'],
['computer', 'click', '--does-not-exist'],
['does-not-exist', '--json']
]
const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]
const summarize = (samples) => ({
importMs: median(samples.map((sample) => sample.importMs)),
totalMs: median(samples.map((sample) => sample.totalMs)),
modules: samples[0].modules
})
const rows = []
for (const args of cases) {
const samples = { before: [], after: [] }
let expected
for (let run = 0; run < 22; run++) {
for (const variant of run % 2 ? ['after', 'before'] : ['before', 'after']) {
const result = runProcessSync({
program: process.execPath,
args: ['-e', child, entries[variant], JSON.stringify(args)],
timeoutMs: 30_000,
env: {
...process.env,
NODE_PATH: [resolve('node_modules'), process.env.NODE_PATH]
.filter(Boolean)
.join(delimiter)
}
})
assert.equal(result.timedOut, false, 'CLI child timed out.')
assert.equal(result.code, 0, result.stderr)
const sample = JSON.parse(result.stdout)
const output = { stdout: sample.stdout, stderr: sample.stderr, exitCode: sample.exitCode }
expected ??= output
assert.deepEqual(output, expected, `${variant} output changed for ${args.join(' ')}`)
if (variant === 'after') {
assert.deepEqual(
sample.featureFormatters,
[],
'Help and syntax errors must skip feature formatters.'
)
}
if (run >= 2) {
samples[variant].push(sample)
}
}
}
assert.ok(samples.after[0].modules < samples.before[0].modules, 'Expected fewer loaded modules.')
rows.push({
args,
before: summarize(samples.before),
after: summarize(samples.after),
output: expected,
samples
})
}
console.log(
JSON.stringify(
{
node: process.version,
platform: process.platform,
measurement:
'Fresh-process import + main; excludes process creation; warmed filesystem; 2 warmups and 20 samples per variant, alternating order.',
entries,
rows
},
null,
2
)
)
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { readFileSync } from 'node:fs'
import Module from 'node:module'
import { dirname, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
// Run from the worktree root: node config/scripts/benchmark-cli-response-framing.mjs <base-ref>
const sourcePath = 'src/cli/runtime/transport.ts'
const baselineRef = process.argv[2]
assert.ok(baselineRef, 'Pass the pre-change transport revision as base-ref.')
const beforeSource = execFileSync('git', ['show', `${baselineRef}:${sourcePath}`], {
encoding: 'utf8'
})
let chunks = []
async function loadTransport(source) {
const built = await build({
stdin: { contents: source, loader: 'ts', resolveDir: dirname(resolve(sourcePath)) },
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
logLevel: 'silent'
})
const module = new Module(resolve(sourcePath))
const originalRequire = module.require.bind(module)
module.require = (name) => {
if (name === 'node:crypto') {
return { randomUUID: () => 'benchmark-request' }
}
if (name !== 'node:net') {
return originalRequire(name)
}
return {
createConnection() {
const socket = new EventEmitter()
socket.setEncoding = () => {}
socket.end = () => {}
socket.destroy = () => {}
socket.write = () => {
for (const chunk of chunks) {
socket.emit('data', chunk)
}
}
queueMicrotask(() => socket.emit('connect'))
return socket
}
}
}
module._compile(built.outputFiles[0].text, resolve(sourcePath))
return module.exports.sendRequest
}
const before = await loadTransport(beforeSource)
const after = await loadTransport(readFileSync(sourcePath, 'utf8'))
const metadata = {
runtimeId: 'benchmark-runtime',
authToken: 'benchmark-token',
transports: [{ kind: 'unix', endpoint: 'injected-socket' }]
}
const run = (sendRequest) => sendRequest(metadata, 'terminal.read', {}, 30000)
async function measure(sendRequest, payloadBytes, repetitions) {
const warmup = await run(sendRequest)
assert.equal(warmup.result.data.length, payloadBytes)
const samples = []
for (let sample = 0; sample < 5; sample++) {
const start = performance.now()
for (let iteration = 0; iteration < repetitions; iteration++) {
await run(sendRequest)
}
samples.push((performance.now() - start) / repetitions)
}
return samples.sort((a, b) => a - b)[2]
}
async function searchedCharacters(sendRequest) {
const original = String.prototype.indexOf
let searched = 0
String.prototype.indexOf = function (needle, position) {
if (needle === '\n') {
searched += this.length - (position ?? 0)
}
return original.call(this, needle, position)
}
try {
await run(sendRequest)
} finally {
String.prototype.indexOf = original
}
return searched
}
const rows = []
for (const [payloadBytes, chunkChars, repetitions] of [
[32, 65536, 1000],
[1024 * 1024, 2 * 1024 * 1024, 20],
[1024 * 1024, 65536, 10],
[1024 * 1024, 4096, 5],
[4 * 1024 * 1024, 4096, 2],
[4 * 1024 * 1024, 256, 1]
]) {
const line = `${JSON.stringify({
id: 'benchmark-request',
ok: true,
result: { data: 'x'.repeat(payloadBytes) },
_meta: { runtimeId: 'benchmark-runtime' }
})}\n`
chunks = []
for (let offset = 0; offset < line.length; offset += chunkChars) {
chunks.push(line.slice(offset, offset + chunkChars))
}
const beforeMs = await measure(before, payloadBytes, repetitions)
const afterMs = await measure(after, payloadBytes, repetitions)
rows.push({
payloadBytes,
chunkChars,
beforeMs: +beforeMs.toFixed(6),
afterMs: +afterMs.toFixed(6),
speedup: +(beforeMs / afterMs).toFixed(2),
beforeSearchedCharacters: await searchedCharacters(before),
afterSearchedCharacters: await searchedCharacters(after)
})
}
console.log(JSON.stringify({ node: process.version, baselineRef, rows }, null, 2))
@@ -0,0 +1,165 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import Module from 'node:module'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
// Pass the pre-change file-explorer-entries.ts snapshot as the only argument.
const baselinePath = process.argv[2]
assert.ok(baselinePath, 'Pass a pre-change file-explorer-entries.ts snapshot.')
const entry = 'src/renderer/src/components/right-sidebar/file-explorer-entries.ts'
const baseline = readFileSync(baselinePath, 'utf8')
assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.')
async function load(useBaseline) {
const result = await build({
stdin: {
contents: `export { isDotfileRelativePath } from './${entry}';
export { createNameFilteredFileExplorerProjection } from './src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts';`,
resolveDir: process.cwd(),
loader: 'ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
logLevel: 'silent',
alias: { '@': resolve('src/renderer/src') },
plugins: useBaseline
? [
{
name: 'baseline-dotfile-predicate',
setup(builder) {
builder.onLoad({ filter: /file-explorer-entries\.ts$/ }, () => ({
contents: baseline,
loader: 'ts'
}))
}
}
]
: []
})
const module = new Module(resolve('dotfile-benchmark.cjs'))
module.paths = Module._nodeModulePaths(process.cwd())
module._compile(result.outputFiles[0].text, module.id)
return module.exports
}
const versions = [await load(true), await load(false)]
let parityCases = 0
function check(path, depth) {
assert.equal(
versions[0].isDotfileRelativePath(path),
versions[1].isDotfileRelativePath(path),
path
)
parityCases++
if (depth > 0) {
for (const character of ['.', '/', '\\', 'a', '\n']) {
check(path + character, depth - 1)
}
}
}
check('', 8)
function measure(functions, iterations = 1) {
let sink = 0
const run = (fn) => {
for (let i = 0; i < iterations; i++) {
sink += Number(fn())
}
}
for (const fn of functions) {
for (let warmup = 0; warmup < 3; warmup++) {
run(fn)
}
}
const samples = [[], []]
for (let round = 0; round < 11; round++) {
for (const variant of round % 2 ? [1, 0] : [0, 1]) {
const start = performance.now()
run(functions[variant])
samples[variant].push(performance.now() - start)
}
}
return {
beforeMs: samples[0].sort((a, b) => a - b)[5],
afterMs: samples[1].sort((a, b) => a - b)[5],
iterations,
sink
}
}
const predicates = []
for (const path of [
'a',
'.env',
'packages/pkg/src/file.tsx',
`a${'.'.repeat(254)}`,
`${'/'.repeat(4096)}.`,
`${'../'.repeat(1000)}file.ts`,
'😀/.你好',
'\n/.\n'
]) {
check(path, 0)
predicates.push({
pathLength: path.length,
prefix: path.slice(0, 40),
...measure(
versions.map((version) => () => version.isDotfileRelativePath(path)),
10_000
)
})
}
const projections = []
for (const count of [1000, 10_000, 100_000]) {
for (const query of ['nonmatching-needle', 'file-42']) {
const args = {
ignoredSet: new Set(['unrelated']),
nameFilter: {
query,
relativePaths: Array.from(
{ length: count },
(_, i) => `packages/package-${i % 50}/src/components/section-${i % 10}/file-${i}.tsx`
)
},
showDotfiles: false,
showGitIgnoredFiles: false,
worktreePath: '/workspace'
}
const functions = versions.map(
(version) => () => version.createNameFilteredFileExplorerProjection(args)
)
const rows = functions.map((fn) => {
const projection = fn()
return Array.from({ length: projection.getVisibleCount() }, (_, i) =>
projection.getRowAtIndex(i)
)
})
assert.deepEqual(rows[0], rows[1])
projections.push({
count,
query,
visibleRows: rows[0].length,
...measure(functions.map((fn) => () => fn().getVisibleCount()))
})
}
}
console.log(
JSON.stringify(
{
node: process.version,
platform: process.platform,
baselinePath: resolve(baselinePath),
parityCases,
samples: 11,
warmups: 3,
predicates,
projections
},
null,
2
)
)
@@ -0,0 +1,72 @@
import { strict as assert } from 'node:assert'
import { EventEmitter } from 'node:events'
import { mkdtemp, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { build } from 'esbuild'
if (!global.gc) {
throw new Error('Run with node --expose-gc')
}
const root = resolve(import.meta.dirname, '../..')
const directory = await mkdtemp(join(tmpdir(), 'orca-sentinel-retention-'))
const output = join(directory, 'sentinel.cjs')
try {
await build({
stdin: {
contents: `export {waitForSentinel} from './src/main/ssh/ssh-relay-deploy-helpers';
export {RELAY_SENTINEL} from './src/main/ssh/relay-protocol';`,
resolveDir: root,
loader: 'ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
packages: 'external',
banner: {
js: `var require = require('node:module').createRequire(${JSON.stringify(join(root, 'package.json'))});`
},
outfile: output
})
const { waitForSentinel, RELAY_SENTINEL } = createRequire(import.meta.url)(output)
const held = []
const banners = []
for (let i = 0; i < 100; i++) {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
stdin: { write: () => true },
close: () => {}
})
const pending = waitForSentinel(channel)
banners.push(feedBanner(channel))
channel.emit('data', Buffer.from(RELAY_SENTINEL))
const transport = await pending
const received = []
transport.onData((bytes) => received.push(bytes.toString()))
channel.emit('data', Buffer.from('frame'))
assert.deepEqual(received, ['frame'])
held.push({ channel, transport })
}
await new Promise((resolve) => setImmediate(resolve))
for (let i = 0; i < 5; i++) {
global.gc()
}
const retained = banners.filter((reference) => reference.deref() !== undefined).length
console.log(
JSON.stringify({
connections: held.length,
bannerBytes: 65536,
retainedBannerBuffers: retained,
retainedBannerBytes: retained * 65536
})
)
} finally {
await rm(directory, { recursive: true, force: true })
}
function feedBanner(channel) {
const banner = Buffer.alloc(65536, 120)
channel.emit('data', banner)
return new WeakRef(banner.buffer)
}
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import * as fs from 'node:fs/promises'
import Module from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
// Pass a pre-change skill-root-file-walk.ts snapshot as the only argument.
const baselinePath = process.argv[2]
const brokenLinks = process.argv.includes('--broken')
assert.ok(baselinePath, 'Pass a pre-change skill-root-file-walk.ts snapshot.')
const entry = 'src/main/skills/skill-root-file-walk.ts'
const baseline = readFileSync(baselinePath, 'utf8')
assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.')
let statCalls = 0
async function load(useBaseline) {
const result = await build({
entryPoints: [entry],
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
logLevel: 'silent',
plugins: useBaseline
? [
{
name: 'baseline-skill-depth',
setup(builder) {
builder.onLoad({ filter: /skill-root-file-walk\.ts$/ }, () => ({
contents: baseline,
loader: 'ts'
}))
}
}
]
: []
})
const module = new Module(resolve('skill-depth-benchmark.cjs'))
module.paths = Module._nodeModulePaths(process.cwd())
const originalRequire = module.require.bind(module)
module.require = (name) =>
name === 'node:fs/promises'
? {
...fs,
stat: (...args) => {
statCalls++
return fs.stat(...args)
}
}
: originalRequire(name)
module._compile(result.outputFiles[0].text, module.id)
return module.exports.findSkillFiles
}
const before = await load(true)
const after = await load(false)
const median = (values) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)]
const temporaryRoot = await fs.mkdtemp(join(tmpdir(), 'orca-skill-depth-benchmark-'))
try {
for (const links of [0, 8, 100, 1000]) {
const root = join(temporaryRoot, String(links))
const edge = join(root, 'a', 'b', 'c', 'd')
const target = join(temporaryRoot, 'target')
await fs.mkdir(edge, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(join(target, 'SKILL.md'), 'skill')
await fs.writeFile(join(edge, 'SKILL.md'), 'edge')
for (let index = 0; index < links; index++) {
await fs.symlink(
brokenLinks ? join(target, 'missing') : target,
join(edge, `link${index}`),
process.platform === 'win32' ? 'junction' : 'dir'
)
}
for (const depth of [4, 5]) {
const timings = { before: [], after: [] }
const counts = {}
let rows
for (let sample = 0; sample < 13; sample++) {
const versions =
sample % 2
? [
['after', after],
['before', before]
]
: [
['before', before],
['after', after]
]
for (const [name, walk] of versions) {
statCalls = 0
const start = performance.now()
const result = await walk(root, depth)
const elapsed = performance.now() - start
if (rows) {
assert.deepEqual(result, rows)
}
rows = result
counts[name] = statCalls
if (sample >= 2) {
timings[name].push(elapsed)
}
}
}
console.log(
JSON.stringify({
links,
brokenLinks,
depth,
statCalls: counts,
rows: rows.length,
medianMs: { before: median(timings.before), after: median(timings.after) }
})
)
}
}
} finally {
await fs.rm(temporaryRoot, { recursive: true, force: true })
}
@@ -0,0 +1,80 @@
import { strict as assert } from 'node:assert'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
const root = resolve(import.meta.dirname, '../..')
const source = join(root, 'src/renderer/src/store/slices/tab-group-reference-repair.ts')
const directory = await mkdtemp(join(tmpdir(), 'orca-tab-repair-'))
const current = await readFile(source, 'utf8')
const indexed = `const orderedTabIds = new Set(group.tabOrder)
const missingTabIds = ownedTabIds.filter((tabId) => !orderedTabIds.has(tabId))`
assert(current.includes(indexed), 'Expected indexed implementation')
try {
const implementations = []
for (const baseline of [true, false]) {
const outfile = join(directory, baseline ? 'before.cjs' : 'after.cjs')
await build({
stdin: {
contents: baseline
? current.replace(
indexed,
'const missingTabIds = ownedTabIds.filter((tabId) => !group.tabOrder.includes(tabId))'
)
: current,
resolveDir: resolve(source, '..'),
loader: 'ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
alias: { '@': join(root, 'src/renderer/src') }
})
implementations.push(createRequire(import.meta.url)(outfile).appendOwnedTabIdsToGroups)
}
const rows = []
for (const count of [1, 10, 100, 1_000, 10_000]) {
for (const missing of [false, true]) {
const ids = Array.from({ length: count }, (_, i) => `tab-${i}`)
const groups = [
{ id: 'group', worktreeId: 'workspace', activeTabId: null, tabOrder: ids, recentTabIds: [] }
]
const owners = new Map(ids.map((id) => [missing ? `missing-${id}` : id, 'group']))
assert.deepEqual(implementations[0](groups, owners), implementations[1](groups, owners))
const iterations = Math.max(1, Math.floor(10_000 / count))
const samples = [[], []]
for (let sample = -3; sample < 11; sample++) {
for (const index of sample % 2 === 0 ? [0, 1] : [1, 0]) {
const start = performance.now()
for (let i = 0; i < iterations; i++) {
implementations[index](groups, owners)
}
const elapsed = (performance.now() - start) / iterations
if (sample >= 0) {
samples[index].push(elapsed)
}
}
}
rows.push({
count,
missing,
iterations,
beforeMs: samples[0].sort((a, b) => a - b)[5],
afterMs: samples[1].sort((a, b) => a - b)[5]
})
}
}
console.log(
JSON.stringify(
{ node: process.version, platform: process.platform, samples: 11, warmups: 3, rows },
null,
2
)
)
} finally {
await rm(directory, { recursive: true, force: true })
}
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import Module from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
const entry = 'src/shared/agent-hook-listener/transcript-reader.ts'
assert.ok(process.argv[2], 'Pass a pre-change transcript-reader.ts snapshot.')
const baseline = readFileSync(process.argv[2], 'utf8')
assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.')
async function load(useBaseline) {
const result = await build({
stdin: {
contents: `export * from './${entry}';
export { extractAssistantTextFromLine } from './src/shared/agent-hook-listener/transcript-entry-text.ts';`,
resolveDir: process.cwd(),
loader: 'ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
logLevel: 'silent',
plugins: useBaseline
? [
{
name: 'baseline-transcript-reader',
setup(builder) {
builder.onLoad({ filter: /transcript-reader\.ts$/ }, () => ({
contents: baseline,
loader: 'ts'
}))
}
}
]
: []
})
const module = new Module(resolve('transcript-benchmark.cjs'))
module.paths = Module._nodeModulePaths(process.cwd())
module._compile(result.outputFiles[0].text, module.id)
return module.exports
}
const versions = [await load(true), await load(false)]
function measure(functions, iterations) {
let sink = 0
const run = (fn) => {
for (let i = 0; i < iterations; i++) {
sink += fn()?.length ?? 0
}
}
for (const fn of functions) {
for (let i = 0; i < 3; i++) {
run(fn)
}
}
const samples = [[], []]
for (let round = 0; round < 11; round++) {
for (const index of round % 2 ? [1, 0] : [0, 1]) {
const start = performance.now()
run(functions[index])
samples[index].push((performance.now() - start) / iterations)
}
}
return {
beforeMs: samples[0].sort((a, b) => a - b)[5],
afterMs: samples[1].sort((a, b) => a - b)[5],
iterations,
sink
}
}
const cases = [
['tiny', `${JSON.stringify({ role: 'assistant', content: 'hello' })}\n`, 10000],
['64KiB line', `${JSON.stringify({ role: 'assistant', content: 'x'.repeat(65500) })}\n`, 100],
[
'4MiB line',
`${JSON.stringify({ role: 'assistant', content: 'x'.repeat(4 * 1024 * 1024 - 40) })}\n`,
10
],
[
'1000 short tool lines',
Array.from({ length: 1000 }, () =>
JSON.stringify({ role: 'tool', content: 'x'.repeat(100) })
).join('\n'),
50
],
[
'Unicode line',
`${JSON.stringify({ role: 'assistant', content: '😀漢字'.repeat(16000) })}\n`,
100
],
[
'leading and trailing blank lines',
`\n\r\n${JSON.stringify({ role: 'assistant', content: 'hello' })}\n\n`,
10000
]
]
const directory = mkdtempSync(join(tmpdir(), 'orca-transcript-benchmark-'))
try {
for (const [name, text, iterations] of cases) {
const file = join(directory, 'transcript.jsonl')
writeFileSync(file, text)
const scanners = versions.map(
(v) => () => v.findLastExtractedTranscriptLineText(text, v.extractAssistantTextFromLine)
)
const readers = versions.map((v) => () => v.readLastAssistantFromTranscriptOnce(file))
assert.equal(scanners[0](), scanners[1](), name)
assert.equal(readers[0](), readers[1](), name)
console.log(
JSON.stringify({
name,
bytes: Buffer.byteLength(text),
scanner: measure(scanners, iterations),
warmFileReader: measure(readers, Math.min(iterations, 100))
})
)
}
} finally {
rmSync(directory, { recursive: true, force: true })
}
@@ -2,7 +2,7 @@
// Equivalence check for deferring the RuntimeClient module graph in the CLI.
//
// Builds the CLI twice with the REAL tsc emit — once from the working tree and
// once with the seven touched files restored from git HEAD~ (the pre-deferral
// once with the touched files restored from git HEAD~ (the pre-deferral
// implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE
// across a matrix of invocations.
//
@@ -13,7 +13,7 @@
//
// Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline <rev>]
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -21,8 +21,11 @@ const REPO = fileURLToPath(new URL('../..', import.meta.url))
// The files this change touches. Restoring exactly these from the baseline rev
// reconstructs the old implementation without disturbing anything else.
// Files absent at the baseline (e.g. cli-error.ts, split out of format.ts
// later) are removed for the baseline build and put back afterwards.
const TOUCHED = [
'src/cli/args.ts',
'src/cli/cli-error.ts',
'src/cli/dispatch.ts',
'src/cli/flags.ts',
'src/cli/format.ts',
@@ -72,12 +75,16 @@ function buildTree(label, baselineRev) {
if (baselineRev) {
for (const file of TOUCHED) {
const path = join(REPO, file)
restored.push([path, readFileSync(path)])
const old = execFileSync('git', ['show', `${baselineRev}:${file}`], {
restored.push([path, existsSync(path) ? readFileSync(path) : null])
const old = spawnSync('git', ['show', `${baselineRev}:${file}`], {
cwd: REPO,
maxBuffer: 64 * 1024 * 1024
})
writeFileSync(path, old)
if (old.status === 0) {
writeFileSync(path, old.stdout)
} else {
rmSync(path, { force: true })
}
}
}
execFileSync(
@@ -97,7 +104,11 @@ function buildTree(label, baselineRev) {
)
} finally {
for (const [path, contents] of restored) {
writeFileSync(path, contents)
if (contents === null) {
rmSync(path, { force: true })
} else {
writeFileSync(path, contents)
}
}
}
return join(outDir, 'cli/index.js')
@@ -103,14 +103,24 @@ describe('electron-builder markdown file associations', () => {
// Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown
// hooks too. electron-builder allows only one include, so a merge that drops the daemon
// sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall.
// sweep would silently orphan a running daemon host on every uninstall.
//
// Asserted against comment-stripped script, and on the app exe name first: the relocated
// host is a verbatim copy of the app exe (daemonHostExeName, daemon-host-relocation.ts),
// so a macro that kills only orca-terminal-daemon.exe matches no running process. The
// prose above the macro names both, so a toContain over the raw file proves nothing.
it('keeps the daemon-host uninstall sweep across the include rename', async () => {
const hooks = await readInstallerHooks()
const script = stripNsisCommentLines(await readInstallerHooks())
expect(hooks).toContain('orca-terminal-daemon.exe')
expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host')
expect(script).toMatch(/taskkill[^\n]*\/IM\s+"?\$\{APP_EXECUTABLE_FILENAME\}"?/)
// Legacy name, so hosts left by builds that renamed the copy still get reaped.
expect(script).toMatch(/taskkill[^\n]*\/IM\s+"?orca-terminal-daemon\.exe"?/)
// Scopes both kills to the uninstalling user: an elevated machine-wide uninstall must
// not reach another logged-on user's session.
expect(script).toMatch(/\/FI\s+"USERNAME eq /)
expect(script).toContain('$LOCALAPPDATA\\Orca\\daemon-host')
// Without this guard, uninstallOldVersion would kill the daemon on every update —
// defeating the relocation that keeps terminals alive across updates.
expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/)
expect(script).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/)
})
})
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'
const root = fileURLToPath(new URL('../..', import.meta.url))
const bundled = await build({
stdin: {
contents: `export { selectDeletionRoots } from './file-explorer-batch-deletion';
export { isPathEqualOrDescendant } from './file-explorer-paths';`,
resolveDir: join(root, 'src/renderer/src/components/right-sidebar'),
loader: 'ts'
},
alias: { '@': join(root, 'src/renderer/src') },
bundle: true,
platform: 'node',
format: 'esm',
write: false,
logLevel: 'silent'
})
const { selectDeletionRoots, isPathEqualOrDescendant } = await import(
`data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}`
)
// Original production selector; both paths use the same path-comparison implementation.
function original(nodes) {
return nodes.filter(
(n) =>
!nodes.some(
(other) => other !== n && other.isDirectory && isPathEqualOrDescendant(n.path, other.path)
)
)
}
function measure(run, nodes) {
for (let index = 0; index < 3; index++) {
run(nodes)
}
const samples = []
for (let index = 0; index < 11; index++) {
const start = performance.now()
run(nodes)
samples.push(performance.now() - start)
}
return samples.sort((a, b) => a - b)[5]
}
const results = []
for (const [fileCount, directoryCount] of [
[100, 0],
[1000, 0],
[5000, 0],
[5000, 5],
[0, 100]
]) {
const nodes = Array.from({ length: fileCount + directoryCount }, (_, index) => ({
name: `item-${index}`,
path: `/repo/item-${index}`,
relativePath: `item-${index}`,
isDirectory: index >= fileCount,
depth: 0
}))
const expected = original(nodes)
const actual = selectDeletionRoots(nodes)
assert.equal(actual.length, expected.length)
actual.forEach((node, index) => assert.equal(node, expected[index]))
results.push({
fileCount,
directoryCount,
beforeMs: measure(original, nodes),
afterMs: measure(selectDeletionRoots, nodes)
})
}
console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2))
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { stripTypeScriptTypes } from 'node:module'
import { performance } from 'node:perf_hooks'
const baseline = process.argv[2]
if (!baseline) {
throw new Error('Usage: node config/scripts/mobile-file-ranking-benchmark.mjs <baseline-ref>')
}
async function load(source) {
const js = stripTypeScriptTypes(source, { mode: 'transform' })
return await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`)
}
function measure(fn, paths, query) {
for (let warmup = 0; warmup < 10; warmup++) {
fn(paths, query, 16)
}
const samples = []
for (let i = 0; i < 9; i++) {
const start = performance.now()
fn(paths, query, 16)
samples.push(performance.now() - start)
}
return samples.sort((a, b) => a - b)[4]
}
const results = []
for (const [file, name] of [
['src/main/runtime/runtime-mobile-file-path-search.ts', 'rankRuntimeMobileFilePaths'],
['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSuggestions']
]) {
const before = (
await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }))
)[name]
const after = (await load(readFileSync(file, 'utf8')))[name]
for (const count of [100, 100000]) {
const paths = Array.from(
{ length: count },
(_, i) => `src/components/workspace/group-${i % 100}/file-${i}.tsx`
)
for (const query of ['file-9', 'missing', 'workspace']) {
assert.deepEqual(after(paths, query, 16), before(paths, query, 16))
results.push({
function: name,
paths: count,
query,
beforeMs: measure(before, paths, query),
afterMs: measure(after, paths, query)
})
}
}
}
console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2))
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
const sourcePath = 'mobile/src/components/mobile-markdown-preview-html.ts'
const baselineRef = process.argv[2]
if (!baselineRef) {
throw new Error(
'Usage: node config/scripts/mobile-markdown-placeholder-benchmark.mjs <baseline-ref>'
)
}
async function load(source) {
const result = await build({
stdin: { contents: source, resolveDir: dirname(resolve(sourcePath)), loader: 'ts' },
bundle: true,
write: false,
platform: 'node',
format: 'esm'
})
return (
await import(
`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`
)
).normalizeMobileMarkdownPreviewHtml
}
const before = await load(
execFileSync('git', ['show', `${baselineRef}:${sourcePath}`], { encoding: 'utf8' })
)
const after = await load(readFileSync(sourcePath, 'utf8'))
function measure(fn, input, repeats) {
const samples = []
for (let run = 0; run < repeats; run++) {
const start = performance.now()
fn(input)
samples.push(performance.now() - start)
}
return samples.sort((a, b) => a - b)[Math.floor(samples.length / 2)]
}
const results = []
for (const [shape, input] of [
['ordinary Markdown', '# Hello\n\n<p>Use `Array<string>` and <b>bold</b>.</p>'],
...[2048, 8192, 16384].map((length) => [
`${length} underscore collision`,
`\uE000ORCA_MD_CODE_${'_'.repeat(length)}0\uE000 and \`Array<string>\``
])
]) {
assert.equal(after(input), before(input))
results.push({
shape,
bytes: Buffer.byteLength(input),
beforeMs: measure(before, input, 5),
afterMs: measure(after, input, 15)
})
}
console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2))
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { hasNativeImeSourceChange, shouldRunReusablePrE2e } from './pr-e2e-source-routing.mjs'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const filterStep = workflow.jobs.code_paths.steps.find((step) => step.id === 'e2e_filter')
describe('native-only PR E2E routing', () => {
it('avoids generic E2E allocation for native-only changes while preserving its IME lane', () => {
for (const file of [
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
]) {
expect(hasNativeImeSourceChange([file])).toBe(true)
expect(shouldRunReusablePrE2e([file])).toBe(false)
}
expect(shouldRunReusablePrE2e([])).toBe(false)
for (const spec of [
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/paired-startup-exec-readiness.spec.ts',
'tests/e2e/terminal-ime-exact-byte.spec.ts',
'tests/e2e/future.spec.ts'
]) {
expect(shouldRunReusablePrE2e([spec])).toBe(true)
expect(shouldRunReusablePrE2e(['tests/e2e/terminal-ibus-hangul-native.spec.ts', spec])).toBe(
true
)
}
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --reusable-workflow')
expect(filterStep.run).toContain('if [ "$SHOULD_RUN" = true ]; then')
})
})
+12
View File
@@ -217,6 +217,16 @@ export function hasNativeImeSourceChange(changedPaths) {
).some((route) => changedPaths.some(route.matches))
}
export function shouldRunReusablePrE2e(changedPaths) {
// Native IME has its own workflow; SSH still runs inside the reusable workflow.
return (
hasSshSourceChange(changedPaths) ||
selectPrE2eSpecs(changedPaths).some(
(spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
)
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
@@ -226,6 +236,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
const changedPaths = input.split(/\r?\n/).filter(Boolean)
if (process.argv.includes('--ssh-source')) {
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
} else if (process.argv.includes('--reusable-workflow')) {
process.stdout.write(`${shouldRunReusablePrE2e(changedPaths)}\n`)
} else if (process.argv.includes('--native-ime-source')) {
process.stdout.write(`${hasNativeImeSourceChange(changedPaths)}\n`)
} else {
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
const bundled = await build({
entryPoints: ['src/shared/quick-open-filter.ts'],
bundle: true,
platform: 'node',
format: 'esm',
write: false,
logLevel: 'silent'
})
const { shouldExcludeQuickOpenRelPath: after } = await import(
`data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}`
)
// Original production predicate, including its exact boundary check.
function before(relPath, prefixes) {
for (const prefix of prefixes) {
if (relPath === prefix) {
return true
}
if (relPath.length > prefix.length && relPath.startsWith(`${prefix}/`)) {
return true
}
}
return false
}
const files = Array.from(
{ length: 100000 },
(_, index) => `src/components/group-${index % 100}/file-${index}.tsx`
)
function run(fn, prefixes) {
let excluded = 0
for (const file of files) {
excluded += Number(fn(file, prefixes))
}
return excluded
}
function measure(fn, prefixes) {
run(fn, prefixes)
const samples = []
for (let index = 0; index < 5; index++) {
const start = performance.now()
run(fn, prefixes)
samples.push(performance.now() - start)
}
return samples.sort((a, b) => a - b)[2]
}
const results = []
for (const count of [0, 10, 100, 500]) {
const prefixes = Array.from({ length: count }, (_, index) => `nested-worktrees/worktree-${index}`)
assert.equal(run(after, prefixes), run(before, prefixes))
results.push({
files: files.length,
exclusions: count,
beforeMs: measure(before, prefixes),
afterMs: measure(after, prefixes)
})
}
console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2))
@@ -0,0 +1,62 @@
#!/usr/bin/env node
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { stripTypeScriptTypes } from 'node:module'
import { performance } from 'node:perf_hooks'
// Pass the pre-change source saved with git show <base>:src/shared/relay-frame-buffer.ts.
const baselinePath = process.argv[2]
if (!baselinePath) {
throw new Error('Usage: node config/scripts/relay-frame-buffer-benchmark.mjs <baseline.ts>')
}
async function load(source) {
return (
await import(
`data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source)).toString('base64')}`
)
).RelayFrameBuffer
}
const Before = await load(readFileSync(baselinePath, 'utf8'))
const After = await load(
readFileSync(new URL('../../src/shared/relay-frame-buffer.ts', import.meta.url), 'utf8')
)
function median(values) {
return values.sort((a, b) => a - b)[Math.floor(values.length / 2)]
}
for (const count of [1, 256, 16384, 65536]) {
const chunks = Array.from({ length: count }, (_, index) => Buffer.alloc(64, index % 256))
const expected = Buffer.concat(chunks)
for (const mode of ['take', 'discard']) {
const times = [[], []]
for (let round = 0; round < 9; round += 1) {
for (const arm of round % 2 === 0 ? [0, 1] : [1, 0]) {
const FrameBuffer = arm === 0 ? Before : After
const buffer = new FrameBuffer()
for (const chunk of chunks) {
buffer.append(chunk)
}
const start = performance.now()
const output = buffer[mode](expected.length)
times[arm].push(performance.now() - start)
if (mode === 'take') {
assert.deepEqual(output, expected)
}
assert.equal(buffer.length, 0)
buffer.append(Buffer.from('tail'))
assert.equal(buffer.drain().toString(), 'tail')
}
}
const beforeMs = median(times[0]),
afterMs = median(times[1])
console.log(
JSON.stringify({
mode,
chunks: count,
bytes: expected.length,
beforeMs,
afterMs,
speedup: beforeMs / afterMs
})
)
}
}
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import { performance } from 'node:perf_hooks'
import { extractIconHref } from '../../src/main/repo-icon-source-href.ts'
// Original production expressions, preserved for the before/after measurement.
const html =
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i
const object =
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i
const original = (source) => source.match(html)?.[1] ?? source.match(object)?.[1] ?? null
function measurePair(source) {
original(source)
extractIconHref(source)
const beforeSamples = []
const afterSamples = []
for (let run = 0; run < 5; run++) {
const measurements = [
[original, beforeSamples],
[extractIconHref, afterSamples]
]
if (run % 2 === 1) {
measurements.reverse()
}
for (const [fn, samples] of measurements) {
const started = performance.now()
fn(source)
samples.push(performance.now() - started)
}
}
return {
beforeMs: beforeSamples.sort((a, b) => a - b)[2],
afterMs: afterSamples.sort((a, b) => a - b)[2]
}
}
const results = []
for (const size of [8192, 16384, 32768]) {
for (const shape of ['no icon', 'rel without href', 'unterminated link starts']) {
const source =
shape === 'unterminated link starts'
? '<link '.repeat(Math.floor(size / 6))
: 'a'.repeat(size) + (shape === 'rel without href' ? ' rel:"icon"' : '')
assert.equal(extractIconHref(source), original(source))
const { beforeMs, afterMs } = measurePair(source)
results.push({
shape,
bytes: Buffer.byteLength(source),
beforeMs,
afterMs,
speedup: beforeMs / afterMs
})
}
}
console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2))
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { stripTypeScriptTypes } from 'node:module'
import { performance } from 'node:perf_hooks'
import { blankStringContents as after } from '../../src/shared/source-scan/source-tree-scan.ts'
const ref = process.argv[2]
if (!ref) {
throw new Error('Usage: node config/scripts/source-string-blanking-benchmark.mjs <baseline-ref>')
}
const source = execFileSync('git', ['show', `${ref}:src/shared/source-scan/source-tree-scan.ts`], {
encoding: 'utf8'
})
const { blankStringContents: before } = await import(
`data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source)).toString('base64')}`
)
const tokens = [
'a',
'/',
'*',
' ',
'\n',
'\r',
'\t',
'\u00a0',
'\u2028',
'"',
"'",
'`',
'${',
'}',
'{',
'\\',
'(',
')',
'[',
']',
'=',
'+',
'-',
';'
]
let seed = 173
for (let sample = 0; sample < 3000; sample++) {
let input = ''
for (let token = 0; token < 40; token++) {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
input += tokens[seed % tokens.length]
}
assert.equal(after(input), before(input), JSON.stringify(input))
assert.equal(after(input, true), before(input, true), JSON.stringify(input))
}
function measure(fn, input) {
const samples = []
for (let run = 0; run < 3; run++) {
const start = performance.now()
fn(input)
samples.push(performance.now() - start)
}
return samples.sort((a, b) => a - b)[1]
}
const results = []
for (const lines of [100, 1000, 5000, 10000]) {
const input = 'const x = value / 2;\n'.repeat(lines)
assert.equal(after(input), before(input))
results.push({
lines,
bytes: Buffer.byteLength(input),
beforeMs: measure(before, input),
afterMs: measure(after, input)
})
}
console.log(
JSON.stringify(
{ node: process.version, platform: process.platform, differentialCases: 3000, results },
null,
2
)
)
@@ -0,0 +1,44 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8'))
// Every step that mirrors this repo's whole ref namespace onto a runner disk to
// prove a commit is reachable from a branch or tag before signing it.
const REF_MIRRORS = [
['.github/workflows/adhoc-mac-build.yml', 'build-adhoc-mac', 'Vet the requested ref'],
['.github/workflows/dev-channel-win-build.yml', 'build-win', 'Vet the requested inputs']
]
describe('ref-mirroring vet steps', () => {
it('keeps the full-history adhoc checkout on the same case-safe backend', () => {
const steps = readWorkflow('.github/workflows/adhoc-mac-build.yml').jobs['build-adhoc-mac']
.steps
const checkout = steps.find((step) => step.name === 'Checkout the requested ref')
expect(checkout.env.GIT_DEFAULT_REF_FORMAT).toBe('reftable')
expect(checkout.with.ref).toBe('${{ steps.vetted.outputs.sha }}')
expect(checkout.with['fetch-depth']).toBe(0)
expect(checkout.with['persist-credentials']).toBe(false)
})
// Why: macOS and Windows runner disks are case-insensitive, and this repo has
// branches that differ only in casing. The files backend cannot store both, and
// it fails the whole fetch rather than the one ref — so the vet step dies before
// any build runs. reftable keys refs in a table instead of file paths.
it.each(REF_MIRRORS)(
'%s creates its scratch repo with the reftable backend',
(path, job, step) => {
const run = readWorkflow(path).jobs[job].steps.find(
(candidate) => candidate.name === step
).run
expect(run).toContain('+refs/heads/*:refs/heads/*')
expect(run).toMatch(/git init\b[^\n]*--ref-format=reftable/)
expect(run).not.toMatch(/git init -q --bare "\$scratch"/)
}
)
})
@@ -0,0 +1,125 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { runProcess } from '../../src/shared/child-process/run-process'
const readWorkflow = (name) => parse(readFileSync(`.github/workflows/${name}.yml`, 'utf8'))
const windowsVet = readWorkflow('dev-channel-win-build').jobs['build-win'].steps.find(
(step) => step.id === 'vetted'
)
const macSteps = readWorkflow('adhoc-mac-build').jobs['build-adhoc-mac'].steps
const macVet = macSteps.find((step) => step.id === 'vetted')
const macCheckout = macSteps.find((step) => step.name === 'Checkout the requested ref')
const directory = mkdtempSync(join(tmpdir(), 'workflow-ref-reachability-'))
const repository = join(directory, 'remote.git')
const identity = {
...process.env,
GIT_AUTHOR_NAME: 'Ref test',
GIT_AUTHOR_EMAIL: 'ref-test@example.com',
GIT_COMMITTER_NAME: 'Ref test',
GIT_COMMITTER_EMAIL: 'ref-test@example.com'
}
let ancestor, upper, lower, untrusted
async function git(args, env = identity) {
const result = await runProcess({ program: 'git', args, env })
expect(result.code, result.stderr).toBe(0)
return result.stdout.trim()
}
beforeAll(async () => {
await git(['init', '--bare', '--ref-format=reftable', repository])
const tree = await git(['-C', repository, 'mktree'])
ancestor = await git(['-C', repository, 'commit-tree', tree, '-m', 'ancestor'])
upper = await git(['-C', repository, 'commit-tree', tree, '-p', ancestor, '-m', 'upper'])
lower = await git(['-C', repository, 'commit-tree', tree, '-p', ancestor, '-m', 'lower'])
untrusted = await git(['-C', repository, 'commit-tree', tree, '-m', 'PR only'])
for (const [ref, sha] of [
['refs/heads/Fix', upper],
['refs/heads/fix', lower],
['refs/pull/1/head', untrusted]
]) {
await git(['-C', repository, 'update-ref', ref, sha])
}
await git(['-C', repository, 'tag', '-a', 'Release', upper, '-m', 'upper tag'])
await git(['-C', repository, 'tag', '-a', 'release', lower, '-m', 'lower tag'])
await git(['-C', repository, 'config', 'uploadpack.allowFilter', 'true'])
})
afterAll(() => rmSync(directory, { recursive: true, force: true }))
async function vet(step, ref) {
const scratch = mkdtempSync(join(directory, 'attempt-'))
const script = join(scratch, 'vet.sh')
writeFileSync(script, step.run)
return runProcess({
program: 'bash',
args: [script],
env: {
...identity,
REPO_URL: pathToFileURL(repository).href,
RUNNER_TEMP: scratch,
GITHUB_OUTPUT: join(scratch, 'output'),
REQUESTED_REF: ref,
REQUESTED_SHA: ref,
CHANNEL: 'hourly',
TAG: 'v1.0.0-hourly.test',
VERSION: '1.0.0-hourly.test'
}
})
}
describe('release ref trust with case-twin names', () => {
it('accepts both branch tips, annotated tags, and their common ancestor', async () => {
for (const sha of [upper, lower, ancestor]) {
const result = await vet(windowsVet, sha)
expect(result.code, result.stderr).toBe(0)
}
for (const ref of ['Fix', 'fix', 'Release', 'release', ancestor]) {
const result = await vet(macVet, ref)
expect(result.code, result.stderr).toBe(0)
}
})
it('rejects PR-only commits even when the server has their objects', async () => {
for (const step of [windowsVet, macVet]) {
const result = await vet(step, untrusted)
expect(result.code).not.toBe(0)
expect(result.stdout).toContain('not reachable from any branch or tag')
}
const result = await vet(macVet, 'refs/pull/1/head')
expect(result.code).not.toBe(0)
expect(result.stdout).toContain('Refusing to build PR ref')
})
it('preserves both case variants in the subsequent full-history checkout', async () => {
const checkout = join(directory, 'checkout')
const env = { ...identity, ...macCheckout.env }
await git(['init', checkout], env)
await git(
[
'-C',
checkout,
'fetch',
'--no-tags',
repository,
'+refs/heads/*:refs/remotes/origin/*',
'+refs/tags/*:refs/tags/*'
],
env
)
await git(['-C', checkout, 'checkout', '--detach', upper], env)
for (const [ref, sha] of [
['refs/remotes/origin/Fix', upper],
['refs/remotes/origin/fix', lower],
['refs/tags/Release', upper],
['refs/tags/release', lower]
]) {
expect(await git(['-C', checkout, 'rev-parse', `${ref}^{commit}`], env)).toBe(sha)
}
expect(await git(['-C', checkout, 'rev-parse', 'HEAD'], env)).toBe(upper)
})
})
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 40m">
<title>downloads: 40m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 41m">
<title>downloads: 41m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">40m</text>
<text x="90" y="14">40m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">41m</text>
<text x="90" y="14">41m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+66
View File
@@ -131,3 +131,69 @@ environments currently exist. An environment-gated design adds a GitHub
approval after each SignPath approval and changes the current automatic inner
signing timeout fallback; those are explicit release-policy decisions, so this
PR leaves production signing behavior unchanged.
## Second audit and hosted trials
- Cloud Verify ran 100 times in a sampled 39-hour window (84 PR and 16 push
runs). Move its four Ubuntu 22.04 jobs from Blacksmith to standard hosted
Ubuntu 22.04, preserving Postgres, secret scanning, build, tests, and Terraform
validation. Baseline [34001538145](https://github.com/stablyai/orca/actions/runs/34001538145)
used 64/72/26/19 seconds for security/test/build/Terraform respectively.
This conserves the shared provider allowance; hosted latency must be checked.
- Keep full tag history for the 13-job skill round-trip matrix, but fetch blobs
lazily. Only two historical SKILL.md files are materialized. Baseline
[33999994876](https://github.com/stablyai/orca/actions/runs/33999994876)
spent 42–84 seconds per checkout, about 14 aggregate runner minutes. A hosted
trial must verify historical blob fetches on all three operating systems.
- Use the existing Electron/native dependency cache for native IME CI. Keep
both deterministic boundary and real IBus tests. Add pnpm store caching to
terminal perf and release golden/evidence lanes; retain their raw installs
because manually selected older refs may not contain the shared action.
- Disable ZIP recompression only for already-compressed NSIS installers sent
to SignPath. Installer contents, release compression, and signing stay intact.
- Advance existing placement and startup deadlines with scoped fake timers in
three renderer test files. All 34 tests pass in 62 ms of local test execution,
versus 65.182 seconds in the sampled hosted baseline. Imports and transforms
still dominate invocation time; this is not a claim of equal PR wall savings.
Eight unit shards already have balanced 260–296-second sample durations.
Reducing shards or removing test isolation lacks evidence of a net gain. Real
subprocess tests intentionally cover lifecycle behavior and retain real clocks.
The 14-way E2E split retains headroom after earlier 12-way timeouts. Lowering
coverage or schedule frequency is outside this efficiency pass. Cache complexity
for a seven-second docs install is unlikely to pay back. Release build reuse
across modes risks differing telemetry identities and native platform artifacts.
Terminal Perf's baseline [33955846492](https://github.com/stablyai/orca/actions/runs/33955846492)
failed waiting 30 seconds for workspaceSessionReady in its shared-page fixture,
before measuring terminal performance. Compare hosted trials against that known
failure rather than attributing it to dependency cache changes.
Hosted trials for the second audit:
- [Cloud Verify 34002295216](https://github.com/stablyai/orca/actions/runs/34002295216)
passed all four jobs on standard hosted Ubuntu: security 57s, test 102s, build
35s, Terraform 19s. The test lane is 30s slower than the Blacksmith sample;
retain this modest latency tradeoff to conserve shared allowance.
- [Skill matrix 34002295221](https://github.com/stablyai/orca/actions/runs/34002295221)
passed all 13 legs, including historical blob materialization. Checkout took
18–20s on Linux, 39–45s on macOS, and 49–58s on Windows, versus the earlier
42–84s range across platforms. These are observational samples.
- [Native IME 34002299594](https://github.com/stablyai/orca/actions/runs/34002299594)
passed both deterministic and real IBus checks. Shared dependency setup took
29s, versus 35s for the old install/toolchain steps in the sampled baseline.
- Native-IME-only source/spec changes no longer allocate the reusable E2E
build, cache, and consumer jobs just to filter out the native spec. The
separate native workflow still runs; SSH-only and mixed spec lists still
allocate the reusable workflow. Routing contracts exercise these cases.
- [Hourly 34001816449](https://github.com/stablyai/orca/actions/runs/34001816449)
exercised the new five-second preflight and successfully published macOS.
The Windows follow-up failed in its unchanged input-vetting fetch because
remote refs differ only by case on its case-insensitive filesystem. The
requested SHA was correct; this does not validate an unchanged-main skip yet.
Moving the daily Mac freshness check has lower expected value than hourly:
only one potential idle allocation per day, and active development usually
requires that build. Defer another release-graph change until skip frequency
justifies it. The substantive remaining release occupancy opportunity is the
separately documented asynchronous signing policy decision.
@@ -0,0 +1,118 @@
# Windows daemon-host relocation
On Windows the terminal daemon does not run from the install directory. Before it forks the
daemon, Orca materializes a trimmed copy of its own runtime under
`%LOCALAPPDATA%\Orca\daemon-host\<app version>\` and forks the daemon from there
(`src/main/daemon/daemon-host-relocation.ts`). This is what keeps live terminals alive across an
auto-update and across a crash of the main process.
Read this before changing the copy plan, the host exe name, the LOCALAPPDATA layout, or
`config/nsis/orca-installer-hooks.nsh`.
## What the relocation actually escapes
The killer is **electron-builder's process sweep, matched on image path** — not file deletion.
Windows will not delete a running image, so `RMDir /r "$INSTDIR"` cannot end the daemon on its own.
In app-builder-lib's `allowOnlyOneInstallerInstance.nsh`, `FIND_PROCESS` / `KILL_PROCESS` have two
branches:
| Branch | Condition | Selector |
| -------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Primary | `powershell.exe` runs, `Get-CimInstance` resolves, and `Get-ExecutionPolicy -Scope Process` is not `Restricted` | `Win32_Process` where `$_.Path.StartsWith('$INSTDIR', 'CurrentCultureIgnoreCase')` — **path-scoped** |
| Fallback | otherwise | per-user: `taskkill /F /IM "<AppName>.exe" /FI "PID ne $pid" /FI "USERNAME eq %USERNAME%"`; per-machine: the same without the username filter — **image-name-scoped** |
The probe reads the **process** scope, not the effective policy, and Group Policy writes
`MachinePolicy`/`UserPolicy` — so a GPO-managed host whose effective policy is `Restricted` still
exits 0 and takes the primary branch. The fallback is reached only when `powershell.exe` is absent,
`Get-CimInstance` does not resolve, PowerShell is blocked outright (WDAC/AppLocker, Server Core), or
an inherited `PSExecutionPolicyPreference=Restricted` is in the environment.
So on essentially every machine the sweep is path-scoped, and a daemon whose image lives under
`%LOCALAPPDATA%` is out of range regardless of what the file is called. **Survival is a property of
the path.** The name only matters on the fallback branch.
## Why the exe is copied verbatim (and not renamed)
The host exe keeps the app exe's own file name (`daemonHostExeName()` returns
`basename(process.execPath)`), so the relocated image is a byte-for-byte copy of the app binary
under its original name.
An earlier revision copied it as `orca-terminal-daemon.exe` specifically so the fallback
`taskkill /IM Orca.exe` could not match. That bought survival on the rare no-PowerShell host and
cost a textbook defence-evasion signature: _a process copies its own image into a user-writable
directory under a different name so a kill-by-image-name cannot match it, then runs detached and
survives the installer._ Microsoft Defender for Endpoint flagged it as MITRE **T1036
(Masquerading)**, and — because it is the process every other flagged action is attributed to — it
acted as a reputation multiplier on unrelated findings. No VS Code fork does this.
Trading the fallback branch for the name is the right trade:
- On the primary branch nothing changes: the daemon still survives the update.
- On the fallback branch the daemon is killed with the app and terminals **cold-restore** on
relaunch. That is the documented pre-relocation behaviour, a first-class outcome the update
harness already asserts (`--expect cold-restore`), not a failure.
- Relocation is fail-open end to end anyway: any materialization failure returns `null` and the
caller forks the install-dir host.
One new failure mode comes with it, on the fallback branch only. The daemon now matches
`FIND_PROCESS` under the app's image name, so it enters electron-builder's retry loop
(`allowOnlyOneInstallerInstance.nsh:136-141`). If the `taskkill` there fails to end it — an elevated
or otherwise unkillable host — the loop reaches `MessageBox ... /SD IDCANCEL` and `Quit`s, aborting a
silent update rather than completing it. Under the old distinct name the daemon was invisible to
that loop. Low probability (fallback branch _and_ an unkillable daemon), but it is a real new path.
What this does **not** buy. Two things bound the win honestly:
- The strongest T1036 indicator is a PE-resource-vs-disk-name mismatch, and it was **never firing**:
the shipped binary's `OriginalFilename` is empty (only `InternalName = Orca` is set), so there was
no embedded name for the old disk name to contradict.
- The remaining behaviour — a signed app copying its own ~225 MB image into user-writable
`%LOCALAPPDATA%` and running it detached under `ELECTRON_RUN_AS_NODE=1` — is still execution from
a non-standard user-writable location, which maps to **T1036.005** and is a standard heuristic on
its own.
So this removes a real but partial signal. Expect the score to drop; do not expect the process to
stop being scored.
## Options that were rejected
| Option | Why not |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Materialize the tree from the NSIS installer | The daemon host is ~246 MB. Writing it at install time doubles install footprint and lengthens the window in which the app is down during a silent update. Worse, on a per-machine install (`INSTALL_MODE_PER_ALL_USERS`) the installer runs as the installing admin, so `$LOCALAPPDATA` is the wrong user's — every other user still needs the runtime path, which means the runtime self-copy stays in the product and the signal is only made rarer. |
| Ship a second signed `orca-terminal-daemon.exe` in the installer | `Orca.exe` is 235,555,328 bytes (224.6 MiB). electron-builder's NSIS uses solid LZMA with a 64 MB dictionary, so a second copy 224 MB downstream does not dedupe; the compressed installer grows by roughly a whole compressed Electron binary, paid by every user on every update download. It also does not remove the runtime copy — the helper still has to reach `%LOCALAPPDATA%` to escape the sweep — so it buys the same signal reduction as the verbatim copy at a large download cost. |
| Override `customCheckAppRunning` to force a path-scoped kill on both branches | Cheap to write (~6 lines: `!include "getProcessInfo.nsh"`, `Var pid`, and a macro that pins `IsPowerShellAvailable`, reusing upstream's dialog, retry loop and elevated handling) — but wrong at any size. Forcing the PowerShell branch on a host where PowerShell is genuinely absent makes `FIND_PROCESS` and `KILL_PROCESS` silently no-op, so the installer proceeds with the **real app** still running and its files in use. That is a worse outcome than the cold restore it would prevent, so this is not worth doing ever, not merely not now. |
| Hardlink instead of copy | Avoids the 246 MB entirely and is not a "copy" at all, but is NTFS-and-same-volume-only and introduces fresh failure modes (link counts, AV interception, cross-volume installs). Worth revisiting deliberately, not as part of a signal fix. |
## Invariants to preserve
- The host exe name is **derived from `process.execPath`**, never a literal. A future
`executableName` or dev-channel rename must follow automatically; pinning a name of our own is
how the mismatch creeps back.
- The daemon is identified by **PID and command line**, never by image name — in the product
(`daemon-pid-file-parse`, `daemon-process-inspection`) and in the harness
(`tests/tools/win-update-e2e/daemon-processes.mjs`). Nothing may start matching on the exe name.
- `config/nsis/orca-installer-hooks.nsh` kills the daemon by image name. That now also matches the
app's own exe, which is correct on a genuine uninstall — the product is being removed — but its
`${isUpdated}` guard must stay: electron-builder runs the uninstaller during every update's
`uninstallOldVersion`, and killing the daemon there defeats the whole feature. The legacy
`orca-terminal-daemon.exe` name stays in the macro to reap hosts left by older builds.
- `LOCAL_HOST_ROOT_NAME` in `daemon-host-relocation.ts` and the path in the uninstall macro are the
same directory. Change both together.
## Verifying a change
Unit coverage lives in `src/main/daemon/daemon-host-relocation.test.ts` (copy plan, verbatim
naming, marker/atomic publish, fail-open, prune veto). Nothing in unit tests can prove survival, so
any change to this file or to the NSIS macro needs the packaged harnesses:
- `.github/workflows/win-update-survival-e2e.yml` — builds an installer from the branch and updates
it over itself with `--expect survival`. The primary proof.
- `.github/workflows/win-crash-survival-e2e.yml` — proves the daemon survives a main-process crash.
- `.github/workflows/windows-terminal-restart-e2e.yml` — terminal restart behaviour.
- `.github/workflows/win-update-e2e.yml` — release-tag-to-release-tag update, both `survival` and
`cold-restore` profiles.
All four are `workflow_dispatch`-only (the two update workflows also carry a push trigger pinned to
one historical feature branch), so they must be dispatched by hand against this branch before
merging a change here — which requires the workflow files to already exist on `main`.
+25 -12
View File
@@ -50,25 +50,37 @@ and `orca-terminal-daemon.exe` report `Valid CN=SignPath Foundation`.
## The behaviours, and why each one exists
### The daemon runs from a renamed copy of our own image
### The daemon runs from a copy of our own image
`src/main/daemon/daemon-host-relocation.ts` copies the Electron runtime into
`%LOCALAPPDATA%\Orca\daemon-host\<version>\` and renames `Orca.exe` to
`orca-terminal-daemon.exe`. The comment on `DAEMON_HOST_EXE_NAME` states the
reason without varnish: _"so the NSIS updater's `taskkill /IM Orca.exe` can't
match it."_
`%LOCALAPPDATA%\Orca\daemon-host\<version>\` and forks the terminal daemon from
there.
It exists because the NSIS installer deletes the old install directory and force-
kills every process imaged under it. Without relocation, an auto-update kills the
terminal daemon and every live terminal with it. The copy is a run-as-node
`Orca.exe` rather than `node.exe` so there is no console flash and asar still
resolves; `config/nsis/daemon-host-uninstall.nsh` reaps it on a real uninstall
resolves; `config/nsis/orca-installer-hooks.nsh` reaps it on a real uninstall
(guarded by `${isUpdated}` so an update's `uninstallOldVersion` never fires it).
**How an EDR reads it: MITRE T1036, masquerading.** A signed executable copied
out of the install directory into `%LOCALAPPDATA%` under a different name, which
then spawns shells, matches the textbook description closely enough that no
behavioural engine can be expected to score it low.
**At the time of these incidents the copy was also renamed** to
`orca-terminal-daemon.exe`, the image name every incident here reports, and
`DAEMON_HOST_EXE_NAME`'s comment stated the reason without varnish: _"so the NSIS
updater's `taskkill /IM Orca.exe` can't match it."_ The rename has since been
removed; the copy now keeps the app exe's own file name, because the updater's
kill sweep is path-scoped on every host that has PowerShell and the rename only
ever bought the no-PowerShell fallback. See
[`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md).
**How an EDR reads it: MITRE T1036, masquerading** — and, for what remains,
**T1036.005**. A signed executable copied out of the install directory into
`%LOCALAPPDATA%` under a different name, which then spawns shells, matches the
textbook description closely enough that no behavioural engine can be expected to
score it low. Dropping the rename removes that literal indicator but not the
underlying shape: execution from a non-standard user-writable location is scored
on its own. Note also that the strongest form of the T1036 signal was never
present here — the shipped binary's `OriginalFilename` is empty, so there was no
embedded name for the old disk name to contradict.
### Every process gets a handle, on a timer
@@ -232,7 +244,8 @@ obfuscated-command-line detector is tuned on.
### The spawn tree itself
`Orca.exe` → `orca-terminal-daemon.exe` → a shell → an agent CLI is what a
`Orca.exe` → the relocated daemon host (`orca-terminal-daemon.exe` in the builds
these incidents cover, `Orca.exe` since) → a shell → an agent CLI is what a
terminal multiplexer for coding agents *is*. `reg.exe` appears from
`src/main/win32-utils.ts`,
`src/main/agent-hooks/managed-hook-owner-identity.ts` and
@@ -363,7 +376,7 @@ The checklist. On Windows, do not reach for:
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | An installer or updater that does not need the rename. Where the rename is load-bearing, document it as such |
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
Two framing rules that outlast the table:
@@ -204,11 +204,18 @@ function escapeRegExp(value: string): string {
}
function codePlaceholderPrefix(content: string): string {
let prefix = CODE_PLACEHOLDER_PREFIX_BASE
while (content.includes(prefix)) {
prefix = `${prefix}_`
let suffixLength = 0
let cursor = 0
while ((cursor = content.indexOf(CODE_PLACEHOLDER_PREFIX_BASE, cursor)) !== -1) {
cursor += CODE_PLACEHOLDER_PREFIX_BASE.length
const suffixStart = cursor
while (content[cursor] === '_') {
cursor += 1
}
// One extra underscore keeps the prefix longer than every authored run.
suffixLength = Math.max(suffixLength, cursor - suffixStart + 1)
}
return prefix
return CODE_PLACEHOLDER_PREFIX_BASE + '_'.repeat(suffixLength)
}
function protectMarkdownCode(content: string): {
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
const marker = '\uE000ORCA_MD_CODE_'
const suffix = '\uE000'
describe('mobile Markdown code placeholder collisions', () => {
it.each([0, 1, 2, 15, 128, 16384])('preserves a literal marker with %i underscores', (length) => {
const literal = `${marker}${'_'.repeat(length)}0${suffix}`
const input = `${literal} and \`Array<string>\`\n\n\`\`\`html\n<p>literal</p>\n\`\`\``
expect(normalizeMobileMarkdownPreviewHtml(input)).toBe(input)
})
it('handles adjacent markers and repeated maximum suffixes', () => {
const literal = `${marker}${marker}__0${suffix}${marker}__1${suffix}${marker}_2${suffix}`
expect(normalizeMobileMarkdownPreviewHtml(`<p>${literal} and \`<div>\`</p>`)).toBe(
`${literal} and \`<div>\``
)
})
it('preserves authored markers across generated suffix orders and HTML islands', () => {
let seed = 173
for (let sample = 0; sample < 500; sample++) {
const literals: string[] = []
for (let index = 0; index < 8; index++) {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
literals.push(`${marker}${'_'.repeat(seed % 32)}${index}${suffix}`)
}
const text = literals.join(' ') + ' and `Array<string>`'
expect(normalizeMobileMarkdownPreviewHtml(`<p>${text}</p>`)).toBe(text)
}
})
})
@@ -81,7 +81,7 @@ export function rankSuggestions(candidates: readonly string[], query: string, li
const substring: string[] = []
for (const candidate of candidates) {
const lower = candidate.toLowerCase()
const base = lower.split('/').pop() ?? lower
const base = lower.slice(lower.lastIndexOf('/') + 1)
if (lower.startsWith(q) || base.startsWith(q)) {
prefix.push(candidate)
} else if (lower.includes(q)) {
@@ -31,7 +31,14 @@ export function directPathForEndpoint(
// instead of holding the supervisor's operation mutex for the full outer bound.
const RECONNECT_GRACE_MS = 2_000
function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Promise<void> {
function waitForAuthenticatedSession(
session: RpcClient,
timeoutMs: number,
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted) {
return Promise.reject(new Error('probe cancelled'))
}
if (session.getState() === 'connected') {
return Promise.resolve()
}
@@ -72,7 +79,13 @@ function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Pro
finish()
reject(new Error('probe session authentication timed out'))
}, timeoutMs)
const onAbort = (): void => {
finish()
reject(new Error('probe cancelled'))
}
signal?.addEventListener('abort', onAbort, { once: true })
function finish(): void {
signal?.removeEventListener('abort', onAbort)
if (timer) {
clearTimeout(timer)
}
@@ -87,8 +100,12 @@ function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Pro
export async function openAuthenticatedDirectEndpoint(
host: HostProfile,
openDirect: (endpoint: string) => RpcClient,
timeoutMs: number
timeoutMs: number,
signal?: AbortSignal
): Promise<{ client: RpcClient; path: Exclude<MobileConnectionPath, 'relay'> } | null> {
if (signal?.aborted) {
return null
}
const endpoints = directEndpointUrls(host)
return await new Promise((resolve) => {
const clients = new Set<RpcClient>()
@@ -110,8 +127,13 @@ export async function openAuthenticatedDirectEndpoint(
continue
}
clients.add(client)
void waitForAuthenticatedSession(client, timeoutMs).then(
void waitForAuthenticatedSession(client, timeoutMs, signal).then(
() => {
if (signal?.aborted) {
client.close()
rejectCandidate()
return
}
if (settled) {
client.close()
return
@@ -0,0 +1,175 @@
import { expect, it, vi } from 'vitest'
import {
dependencies,
FakeLogicalClient,
FakeSession,
host
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import { createStableLogicalRpcClient } from './stable-logical-rpc-client'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
it('closes in-flight candidates and clears their timeout when the owner stops', async () => {
vi.useFakeTimers()
try {
const candidate = new FakeSession('connecting')
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies({ openDirect: vi.fn(() => candidate) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(15_000)
expect(deps.openDirect).toHaveBeenCalledOnce()
supervisor.stop()
await vi.advanceTimersByTimeAsync(0)
expect(candidate.close).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(12_000)
expect(vi.getTimerCount()).toBe(0)
expect(candidate.close).toHaveBeenCalledOnce()
expect(logical.migrateTo).not.toHaveBeenCalled()
expect(deps.openDirect).toHaveBeenCalledOnce()
} finally {
vi.restoreAllMocks()
vi.useRealTimers()
}
})
it('closes an authenticated candidate when stop races its completion', async () => {
vi.useFakeTimers()
try {
const candidate = new FakeSession('connecting')
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies({ openDirect: vi.fn(() => candidate) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(15_000)
candidate.publishState('connected')
supervisor.stop()
await vi.advanceTimersByTimeAsync(0)
expect(candidate.close).toHaveBeenCalledOnce()
expect(logical.migrateTo).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.restoreAllMocks()
vi.useRealTimers()
}
})
it('preserves an in-flight probe across a transient background pause', async () => {
vi.useFakeTimers()
try {
const candidate = new FakeSession('connecting')
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies({ openDirect: vi.fn(() => candidate) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(15_000)
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(0)
expect(candidate.close).not.toHaveBeenCalled()
supervisor.stop()
await vi.advanceTimersByTimeAsync(0)
expect(candidate.close).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.restoreAllMocks()
vi.useRealTimers()
}
})
it('releases every candidate when multiple endpoint probes are pending', async () => {
vi.useFakeTimers()
try {
const candidates: FakeSession[] = []
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies({
openDirect: vi.fn(() => {
const candidate = new FakeSession('connecting')
candidates.push(candidate)
return candidate
})
})
const supervisor = new MobileEndpointSupervisor(
logical,
{
...host,
endpoints: [{ id: 'alternate', kind: 'tailscale', url: 'ws://100.64.0.2:6768' }]
},
deps
)
await supervisor.start()
await vi.advanceTimersByTimeAsync(15_000)
expect(candidates).toHaveLength(2)
supervisor.stop()
await vi.advanceTimersByTimeAsync(0)
expect(vi.getTimerCount()).toBe(0)
for (const candidate of candidates) {
expect(candidate.close).toHaveBeenCalledOnce()
candidate.publishState('connected')
}
await vi.advanceTimersByTimeAsync(60_000)
expect(logical.migrateTo).not.toHaveBeenCalled()
expect(deps.openDirect).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.restoreAllMocks()
vi.useRealTimers()
}
})
it.each([false, true])(
'fences migration finishing after stop (already swapped: %s)',
async (alreadySwapped) => {
vi.useFakeTimers()
try {
const recordedMigration = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordMigration')
const relay = new FakeSession('connected')
const logical = createStableLogicalRpcClient(relay, 'relay')
const candidates: FakeSession[] = []
const deps = dependencies({
openDirect: vi.fn(() => {
const candidate = new FakeSession('connected')
candidates.push(candidate)
return candidate
})
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const migrate = logical.migrateTo.bind(logical)
let release!: () => void
const pending = new Promise<void>((resolve) => {
release = resolve
})
const migration = vi.spyOn(logical, 'migrateTo').mockImplementation(async (...args) => {
if (alreadySwapped) {
await migrate(...args)
}
await pending
if (!alreadySwapped) {
await migrate(...args)
}
})
await supervisor.start()
await vi.advanceTimersByTimeAsync(60_000)
expect(migration).toHaveBeenCalledOnce()
const requestsBeforeStop = relay.sendRequest.mock.calls.length
const candidateRequestsBeforeStop = candidates[3].sendRequest.mock.calls.length
const migrationsBeforeStop = recordedMigration.mock.calls.length
supervisor.stop()
release()
await vi.advanceTimersByTimeAsync(0)
expect(logical.getActivePath()).toBe(alreadySwapped ? 'lan' : 'relay')
expect(logical.getGeneration()).toBe(alreadySwapped ? 2 : 1)
expect(relay.sendRequest).toHaveBeenCalledTimes(requestsBeforeStop)
expect(candidates[3].sendRequest).toHaveBeenCalledTimes(candidateRequestsBeforeStop)
expect(recordedMigration).toHaveBeenCalledTimes(migrationsBeforeStop)
expect(candidates[3].close).toHaveBeenCalledTimes(alreadySwapped ? 0 : 1)
expect(vi.getTimerCount()).toBe(0)
logical.close()
} finally {
vi.restoreAllMocks()
vi.useRealTimers()
}
}
)
@@ -11,6 +11,9 @@ const DIRECT_PROBE_INTERVAL_MS = 15_000
export class DirectReturnProbe {
private timer: ReturnType<typeof setTimeout> | null = null
private stopped = false
private activeProbe: AbortController | null = null
constructor(
private readonly deps: {
now: () => number
@@ -24,14 +27,18 @@ export class DirectReturnProbe {
canSchedule: () => boolean
canAttempt: () => boolean
beginOperation: () => void
migrate: (client: RpcClient, path: MobileConnectionPath) => Promise<void>
migrate: (
client: RpcClient,
path: MobileConnectionPath,
shouldAbort: () => boolean
) => Promise<void>
onDirectMigrated: () => Promise<void>
afterProbe: () => void
}
) {}
schedule(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
if (!this.hooks.canSchedule() || this.timer) {
if (this.stopped || !this.hooks.canSchedule() || this.timer) {
return
}
this.timer = this.deps.setTimer(() => {
@@ -47,19 +54,34 @@ export class DirectReturnProbe {
}
}
stop(): void {
this.stopped = true
this.clear()
this.activeProbe?.abort()
}
private async probe(): Promise<void> {
if (this.stopped) {
return
}
if (!this.hooks.canAttempt() || !this.hooks.hysteresis.canProbe(this.deps.now())) {
this.schedule()
return
}
const controller = new AbortController()
this.activeProbe = controller
this.hooks.beginOperation()
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
try {
successful = await openAuthenticatedDirectEndpoint(
this.hooks.host(),
this.deps.openDirect,
12_000
12_000,
controller.signal
)
if (this.stopped) {
return
}
if (!successful) {
this.hooks.hysteresis.recordDirectFailure(this.deps.now())
return
@@ -68,11 +90,24 @@ export class DirectReturnProbe {
successful.client.close()
return
}
await this.hooks.migrate(successful.client, successful.path)
const candidate = successful
// Migration owns the candidate, including closing it if cutover is canceled.
successful = null
try {
await this.hooks.migrate(candidate.client, candidate.path, () => this.stopped)
} catch (error) {
if (this.stopped) {
return
}
throw error
}
if (this.stopped) {
return
}
this.hooks.hysteresis.recordMigration(this.deps.now())
await this.hooks.onDirectMigrated()
} finally {
this.activeProbe = null
successful?.client.close()
// Why: a relay drop or backoff timer can arrive while the probe owns the
// operation mutex; afterProbe releases it and replays deferred recovery.
@@ -120,7 +120,7 @@ export class MobileEndpointSupervisor {
canSchedule: () => this.isActive() && this.logical.getActivePath() === 'relay',
canAttempt: () => this.isActive() && !this.operationInFlight,
beginOperation: () => (this.operationInFlight = true),
migrate: (client, path) => this.logical.migrateTo(client, path),
migrate: (client, path, abort) => this.logical.migrateTo(client, path, undefined, abort),
onDirectMigrated: async () => {
this.leaseRotation.clear()
this.relayRotationPending = false
@@ -195,6 +195,7 @@ export class MobileEndpointSupervisor {
stop(): void {
this.stopped = true
this.directProbe.stop()
this.unsubscribeState?.()
this.unsubscribeState = null
this.backgroundGrace.stop()
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { RuntimeBrowserScreencastController } from '../../../src/main/runtime/runtime-browser-screencast-controller'
import type { RuntimeBrowserCommands } from '../../../src/main/runtime/orca-runtime-browser'
import type { BrowserScreencastResult } from '../../../src/shared/runtime-types'
import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
import type { RpcResponse } from './types'
describe('relay browser cancellation resource budget', () => {
it.each([false, true])('stops host frames when cancellation precedes ready=%s', async (early) => {
const subscriptions = new Map<string, () => void | Promise<void>>()
const done = Promise.withResolvers<void>()
const ready = Promise.withResolvers<RpcResponse>()
let sequence = 0
let stopped = false
let frameSends = 0
let frameBytes = 0
let sendBinary: (bytes: Uint8Array) => boolean | void = () => false
let hostRun: Promise<void> | undefined
const methods: string[] = []
const cleanup = (id: string): void => {
const release = subscriptions.get(id)
subscriptions.delete(id)
void release?.()
}
const host = new RuntimeBrowserScreencastController({
getCommands: () =>
({
browserScreencast: async (_params, stream) => {
sendBinary = stream.sendBinary
return {
subscriptionId: 'server-stream',
ready: { type: 'ready', subscriptionId: 'server-stream', browserPageId: 'page' },
session: {
done: done.promise,
stop: () => {
stopped = true
done.resolve()
}
},
flushPendingFrame: () => {}
}
}
}) as RuntimeBrowserCommands,
registerSubscriptionCleanup: (id, release) => subscriptions.set(id, release),
cleanupSubscription: cleanup,
getDriver: () => ({ kind: 'idle' }),
setDriver: () => {},
notifyRemoteViewersChanged: () => {}
})
const streams = new MobileRelayRpcStreams({
nextId: () => `request-${++sequence}`,
waitForConnected: async () => {},
sendFrame: (request) => {
methods.push(request.method)
if (request.method === 'browser.screencast' && (request.params as { page?: string }).page) {
hostRun = host.start(request.params as Parameters<typeof host.start>[0], {
connectionId: 'relay-connection',
sendBinary: (bytes) => {
frameSends++
frameBytes += bytes.byteLength
return true
},
emit: (result: BrowserScreencastResult) => {
if (result.type === 'ready') {
ready.resolve({
id: request.id,
ok: true,
streaming: true,
result,
_meta: { runtimeId: 'host' }
})
}
}
})
} else if (request.method === 'browser.screencast.unsubscribe') {
cleanup((request.params as { subscriptionId: string }).subscriptionId)
}
return true
}
})
const cancel = streams.subscribe('browser.screencast', { page: 'page' }, () => {})
try {
const response = await ready.promise
if (early) {
cancel()
}
streams.handleResponse(response)
if (!early) {
cancel()
}
for (let frame = 0; frame < 100; frame++) {
if (!stopped) {
sendBinary(new Uint8Array(65_536))
}
}
expect({ stopped, subscriptions: subscriptions.size, frameSends, frameBytes }).toEqual({
stopped: true,
subscriptions: 0,
frameSends: 0,
frameBytes: 0
})
expect(methods).toEqual(['browser.screencast', 'browser.screencast.unsubscribe'])
} finally {
cleanup('server-stream')
await hostRun
streams.clear()
}
})
})
@@ -136,6 +136,36 @@ describe('mobile relay RPC session', () => {
})
afterEach(() => vi.useRealTimers())
it('releases stream listeners on failure even when close follows it', async () => {
const { session } = await authenticateSession()
const listener = vi.fn()
session.subscribe('runtime.clientEvents.subscribe', {}, listener)
await Promise.resolve()
const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as { id: string }
fakes.linkOptions!.onText(
JSON.stringify({
id: request.id,
ok: true,
streaming: true,
result: { type: 'ready', subscriptionId: 'server-events' },
_meta: { runtimeId: 'runtime-1' }
})
)
expect(listener).toHaveBeenCalledTimes(1)
fakes.linkOptions!.onError(new Error('relay lost'))
session.close()
fakes.linkOptions!.onText(
JSON.stringify({
id: request.id,
ok: true,
streaming: true,
result: { type: 'event' },
_meta: { runtimeId: 'runtime-1' }
})
)
expect(listener).toHaveBeenCalledTimes(1)
})
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
const { session, confirmationRequest, capabilityRequest } = await authenticateSession()
@@ -294,6 +294,7 @@ export function connectMobileRelayRpcSession(args: {
closed = true
failure = error
livenessWatchdog.stop(livenessIdentity)
streams.clear()
link.close()
pending.rejectAll(error)
publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected')
@@ -0,0 +1,259 @@
import { describe, expect, it, vi } from 'vitest'
import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
import type { RpcResponse } from './types'
function createStreams(waitForConnected = async () => {}) {
let sequence = 0
const sendFrame = vi.fn((_request: { id: string; method: string; params?: unknown }) => true)
const streams = new MobileRelayRpcStreams({
nextId: () => `request-${++sequence}`,
sendFrame,
waitForConnected
})
return { streams, sendFrame }
}
function response(id: string, result: unknown): RpcResponse {
return { id, ok: true, streaming: true, result, _meta: { runtimeId: 'test' } }
}
const serverSubscriptions = [
['browser.screencast', 'browser.screencast.unsubscribe'],
['runtime.clientEvents.subscribe', 'runtime.clientEvents.unsubscribe']
] as const
describe('mobile relay subscription cancellation', () => {
it.each(serverSubscriptions)('cleans up ready %s exactly once', async (method, unsubscribe) => {
const { streams, sendFrame } = createStreams()
const listener = vi.fn()
const cancel = streams.subscribe(method, {}, listener)
await Promise.resolve()
streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' }))
cancel()
cancel()
expect(sendFrame.mock.calls).toEqual([
[{ id: 'request-1', method, params: {} }],
[{ id: 'request-2', method: unsubscribe, params: { subscriptionId: 'server-1' } }]
])
expect(streams.handleResponse(response('request-1', { type: 'end' }))).toBe(false)
expect(listener).toHaveBeenCalledTimes(1)
})
it.each(serverSubscriptions)(
'cleans up late-ready %s without calling disposed listeners',
async (method, unsubscribe) => {
const { streams, sendFrame } = createStreams()
const listener = vi.fn()
const cancel = streams.subscribe(method, {}, listener)
await Promise.resolve()
cancel()
cancel()
expect(sendFrame).toHaveBeenCalledTimes(1)
expect(streams.handleResponse(response('request-1', { type: 'starting' }))).toBe(true)
streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' }))
expect(sendFrame).toHaveBeenLastCalledWith({
id: 'request-2',
method: unsubscribe,
params: { subscriptionId: 'server-1' }
})
expect(
streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' }))
).toBe(false)
expect(listener).not.toHaveBeenCalled()
}
)
it.each(['error', 'end', 'disconnect', 'completed'])(
'forgets cancelled cleanup routes on %s',
async (ending) => {
const { streams, sendFrame } = createStreams()
const cancel = streams.subscribe('browser.screencast', {}, vi.fn())
await Promise.resolve()
cancel()
if (ending === 'disconnect') {
streams.clear()
} else if (ending === 'completed') {
streams.handleResponse({
id: 'request-1',
ok: true,
result: null,
_meta: { runtimeId: 'test' }
})
} else if (ending === 'error') {
streams.handleResponse({
id: 'request-1',
ok: false,
error: { code: 'unsupported', message: 'failed' },
_meta: { runtimeId: 'test' }
})
} else {
streams.handleResponse(response('request-1', { type: 'end', subscriptionId: 'server-1' }))
}
expect(
streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' }))
).toBe(false)
expect(sendFrame).toHaveBeenCalledTimes(1)
}
)
it.each([
[
'terminal.subscribe',
{ terminal: 'term', client: { id: 'phone' } },
'terminal.unsubscribe',
{ subscriptionId: 'term:phone', client: { id: 'phone' } }
],
[
'session.tabs.subscribe',
{ worktree: 'id:workspace' },
'session.tabs.unsubscribe',
{ worktree: 'id:workspace', subscriptionId: 'request-1' }
],
[
'nativeChat.subscribe',
{ subscriptionId: 'chat' },
'nativeChat.unsubscribe',
{ subscriptionId: 'chat' }
]
])(
'cancels %s using its request cleanup identity',
async (method, params, unsubscribe, unsubscribeParams) => {
const { streams, sendFrame } = createStreams()
const cancel = streams.subscribe(method as string, params, vi.fn())
await Promise.resolve()
if (method === 'session.tabs.subscribe') {
streams.handleResponse(response('request-1', { type: 'snapshot' }))
}
cancel()
expect(sendFrame).toHaveBeenLastCalledWith({
id: 'request-2',
method: unsubscribe,
params: unsubscribeParams
})
}
)
it.each([
'terminal.subscribe',
'browser.screencast',
'runtime.clientEvents.subscribe',
'session.tabs.subscribe',
'nativeChat.subscribe'
])('does not unsubscribe an unsent %s', async (method) => {
const wait = Promise.withResolvers<void>()
const { streams, sendFrame } = createStreams(() => wait.promise)
const cancel = streams.subscribe(
method,
{ terminal: 'term', worktree: 'id:workspace', subscriptionId: 'chat' },
vi.fn()
)
cancel()
wait.resolve()
await Promise.resolve()
expect(sendFrame).not.toHaveBeenCalled()
expect(
streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' }))
).toBe(false)
})
it.each([false, true])(
'preserves a same-worktree sibling when cancellation precedes snapshot=%s',
async (early) => {
const { streams, sendFrame } = createStreams()
const first = vi.fn()
const second = vi.fn()
const cancel = streams.subscribe(
'session.tabs.subscribe',
{ worktree: 'id:workspace' },
first
)
streams.subscribe('session.tabs.subscribe', { worktree: 'id:workspace' }, second)
await Promise.resolve()
if (early) {
cancel()
}
expect(sendFrame).toHaveBeenCalledTimes(2)
streams.handleResponse(response('request-1', { type: 'snapshot' }))
if (!early) {
cancel()
}
expect(sendFrame).toHaveBeenLastCalledWith({
id: 'request-3',
method: 'session.tabs.unsubscribe',
params: { worktree: 'id:workspace', subscriptionId: 'request-1' }
})
streams.handleResponse(response('request-2', { type: 'snapshot' }))
streams.handleResponse(response('request-2', { type: 'updated' }))
expect(second).toHaveBeenCalledTimes(2)
expect(first).toHaveBeenCalledTimes(early ? 0 : 1)
expect(streams.handleResponse(response('request-1', { type: 'updated' }))).toBe(false)
}
)
it.each([
['nativeChat.subscribe', { agent: 'claude', sessionId: 's1', subscriptionId: 'claude:s1' }],
['terminal.subscribe', { terminal: 'term', client: { id: 'phone' } }]
])(
'keeps the newer %s live when an older same-token subscription unmounts',
async (method, params) => {
const { streams, sendFrame } = createStreams()
const older = vi.fn()
const newer = vi.fn()
const cancelOlder = streams.subscribe(method, params, older)
const cancelNewer = streams.subscribe(method, { ...params }, newer)
await Promise.resolve()
expect(sendFrame).toHaveBeenCalledTimes(2)
cancelOlder()
// The host keys cleanup by the deterministic token, so unsubscribing would evict the newer.
expect(sendFrame).toHaveBeenCalledTimes(2)
streams.handleResponse(response('request-2', { type: 'snapshot' }))
expect(newer).toHaveBeenCalledTimes(1)
expect(streams.handleResponse(response('request-1', { type: 'snapshot' }))).toBe(false)
expect(older).not.toHaveBeenCalled()
cancelNewer()
expect(sendFrame).toHaveBeenCalledTimes(3)
expect(sendFrame).toHaveBeenLastCalledWith(
expect.objectContaining({ method: method.replace(/\.subscribe$/, '.unsubscribe') })
)
}
)
it('still unsubscribes a shared-token nativeChat stream when the sibling is unsent', async () => {
const wait = Promise.withResolvers<void>()
let connected = false
const { streams, sendFrame } = createStreams(() =>
connected ? Promise.resolve() : wait.promise
)
const params = { agent: 'claude', sessionId: 's1', subscriptionId: 'claude:s1' }
connected = true
const cancelOlder = streams.subscribe('nativeChat.subscribe', params, vi.fn())
await Promise.resolve()
connected = false
streams.subscribe('nativeChat.subscribe', params, vi.fn())
cancelOlder()
expect(sendFrame).toHaveBeenCalledTimes(2)
expect(sendFrame).toHaveBeenLastCalledWith({
id: 'request-3',
method: 'nativeChat.unsubscribe',
params: { subscriptionId: 'claude:s1' }
})
})
it('cleans up every cancelled server subscription across repeated late-ready cycles', async () => {
const { streams, sendFrame } = createStreams()
const listener = vi.fn()
for (let i = 0; i < 100; i++) {
const cancel = streams.subscribe('runtime.clientEvents.subscribe', {}, listener)
await Promise.resolve()
const requestId = `request-${2 * i + 1}`
cancel()
streams.handleResponse(response(requestId, { type: 'ready', subscriptionId: `server-${i}` }))
}
expect(
sendFrame.mock.calls.filter(
([request]) => (request as { method: string }).method === 'runtime.clientEvents.unsubscribe'
)
).toHaveLength(100)
expect(listener).not.toHaveBeenCalled()
})
})
+104 -15
View File
@@ -4,9 +4,11 @@ import {
type TerminalSnapshotState
} from './rpc-client-terminal-binary-frame'
import {
buildStreamUnsubscribe,
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription'
import type { RpcClient } from './rpc-client'
import type { RpcResponse, RpcSuccess } from './types'
@@ -22,6 +24,23 @@ type StreamRecord = {
streamIds: Set<number>
subscriptionId?: string
cancelled: boolean
sent: boolean
receivedSnapshot?: boolean
}
type StreamUnsubscribe = { method: string; params: unknown }
/** Unsubscribe derived from the subscribe params alone (no server-assigned id). */
function buildParamsUnsubscribe(
method: string,
params: unknown,
requestId: string
): StreamUnsubscribe | null {
if (method === 'terminal.subscribe') {
const unsubscribeParams = buildTerminalUnsubscribeParams(params)
return unsubscribeParams ? { method: 'terminal.unsubscribe', params: unsubscribeParams } : null
}
return buildStreamUnsubscribe(method, params, requestId)
}
type StreamManagerOptions = {
@@ -32,6 +51,10 @@ type StreamManagerOptions = {
export class MobileRelayRpcStreams {
private readonly streams = new Map<string, StreamRecord>()
private readonly cancelledSubscriptions = new Map<
string,
{ method: string; unsubscribe?: StreamUnsubscribe }
>()
private readonly terminalListeners = new Map<number, (result: unknown) => void>()
private readonly terminalSnapshots = new Map<number, TerminalSnapshotState>()
private activeBrowserStream: StreamRecord | null = null
@@ -51,13 +74,15 @@ export class MobileRelayRpcStreams {
listener,
onBinaryFrame: subscribeOptions?.onBinaryFrame,
streamIds: new Set(),
cancelled: false
cancelled: false,
sent: false
}
this.streams.set(id, stream)
void this.options
.waitForConnected()
.then(() => {
if (!stream.cancelled) {
stream.sent = true
if (!this.options.sendFrame({ id, method, params: stream.params })) {
this.fail(id, stream, 'Connection interrupted')
}
@@ -75,6 +100,30 @@ export class MobileRelayRpcStreams {
}
handleResponse(response: RpcResponse): boolean {
const cancelled = this.cancelledSubscriptions.get(response.id)
if (cancelled) {
if (!response.ok) {
this.cancelledSubscriptions.delete(response.id)
} else if (response.result && typeof response.result === 'object') {
const result = response.result as { subscriptionId?: unknown; type?: unknown }
if (result.type === 'end') {
this.cancelledSubscriptions.delete(response.id)
} else if (result.type === 'snapshot' && cancelled.unsubscribe) {
this.cancelledSubscriptions.delete(response.id)
this.options.sendFrame({ id: this.options.nextId(), ...cancelled.unsubscribe })
} else if (typeof result.subscriptionId === 'string') {
this.cancelledSubscriptions.delete(response.id)
const unsubscribe = buildReadyStreamUnsubscribe(cancelled.method, result.subscriptionId)
if (unsubscribe) {
this.options.sendFrame({ id: this.options.nextId(), ...unsubscribe })
}
}
}
if (response.ok && response.streaming !== true) {
this.cancelledSubscriptions.delete(response.id)
}
return true
}
const stream = this.streams.get(response.id)
if (!stream) {
return false
@@ -86,6 +135,9 @@ export class MobileRelayRpcStreams {
const result = (response as RpcSuccess).result
if (result && typeof result === 'object') {
const metadata = result as { subscriptionId?: unknown; streamId?: unknown; type?: unknown }
if (stream.method === 'session.tabs.subscribe' && metadata.type === 'snapshot') {
stream.receivedSnapshot = true
}
if (typeof metadata.subscriptionId === 'string') {
stream.subscriptionId = metadata.subscriptionId
}
@@ -125,6 +177,7 @@ export class MobileRelayRpcStreams {
stream.cancelled = true
}
this.streams.clear()
this.cancelledSubscriptions.clear()
this.terminalListeners.clear()
this.terminalSnapshots.clear()
this.activeBrowserStream = null
@@ -136,25 +189,61 @@ export class MobileRelayRpcStreams {
return
}
stream.cancelled = true
if (stream.method === 'terminal.subscribe') {
const params = buildTerminalUnsubscribeParams(stream.params)
if (params) {
this.options.sendFrame({
id: this.options.nextId(),
method: 'terminal.unsubscribe',
params
})
if (stream.sent) {
const byParams = buildParamsUnsubscribe(stream.method, stream.params, id)
if (stream.method === 'terminal.subscribe') {
if (byParams) {
this.sendUnsubscribe(byParams)
}
} else {
const unsubscribe = stream.subscriptionId
? buildReadyStreamUnsubscribe(stream.method, stream.subscriptionId)
: null
if (byParams && stream.method === 'session.tabs.subscribe' && !stream.receivedSnapshot) {
// The host registers cleanup only after resolving the initial snapshot.
this.cancelledSubscriptions.set(id, { method: stream.method, unsubscribe: byParams })
} else if (unsubscribe || byParams) {
this.sendUnsubscribe((unsubscribe ?? byParams)!)
} else if (
stream.method === 'browser.screencast' ||
stream.method === 'runtime.clientEvents.subscribe'
) {
// Keep only the cleanup route while the server assigns its subscription ID.
this.cancelledSubscriptions.set(id, { method: stream.method })
} else if (stream.subscriptionId) {
this.sendUnsubscribe({
method: stream.method.replace(/\.subscribe$/, '.unsubscribe'),
params: { subscriptionId: stream.subscriptionId }
})
}
}
} else if (stream.subscriptionId) {
this.options.sendFrame({
id: this.options.nextId(),
method: stream.method.replace(/\.subscribe$/, '.unsubscribe'),
params: { subscriptionId: stream.subscriptionId }
})
}
this.remove(id)
}
/** Skip the unsubscribe when a live sibling shares the host cleanup token (e.g. nativeChat's
* deterministic `agent:sessionId`), since the host would evict the sibling's registration. */
private sendUnsubscribe(unsubscribe: StreamUnsubscribe): void {
if (this.hasLiveOwner(unsubscribe)) {
return
}
this.options.sendFrame({ id: this.options.nextId(), ...unsubscribe })
}
private hasLiveOwner(unsubscribe: StreamUnsubscribe): boolean {
const token = JSON.stringify(unsubscribe)
for (const [siblingId, sibling] of this.streams) {
if (sibling.cancelled || !sibling.sent) {
continue
}
const siblingUnsubscribe = buildParamsUnsubscribe(sibling.method, sibling.params, siblingId)
if (siblingUnsubscribe && JSON.stringify(siblingUnsubscribe) === token) {
return true
}
}
return false
}
private remove(id: string): void {
const stream = this.streams.get(id)
if (!stream) {
@@ -38,7 +38,8 @@ export function updateTerminalSubscriptionViewport(
* the per-method echo logic out of the rpc-client teardown closure. */
export function buildStreamUnsubscribe(
method: string | undefined,
params: unknown
params: unknown,
requestId?: string
): { method: string; params: Record<string, unknown> } | null {
if (!params || typeof params !== 'object') {
return null
@@ -46,7 +47,10 @@ export function buildStreamUnsubscribe(
if (method === 'session.tabs.subscribe') {
const worktree = (params as { worktree?: unknown }).worktree
return typeof worktree === 'string'
? { method: 'session.tabs.unsubscribe', params: { worktree } }
? {
method: 'session.tabs.unsubscribe',
params: { worktree, ...(requestId ? { subscriptionId: requestId } : {}) }
}
: null
}
if (method === 'nativeChat.subscribe') {
+144
View File
@@ -0,0 +1,144 @@
import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
import {
matchAutomationOwnerConflict,
stripAutomationOwnerConflictCode
} from '../shared/automation-owner-conflict'
import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
import type { RuntimeRpcFailure } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
type CliErrorContext = {
commandPath?: readonly string[]
}
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
if (hasOrchestrationRequestId(error.data)) {
return message
}
return `${message}\nOrca is not running. Run 'orca open' first.`
}
// Why: error-specific recovery must win over the generic computer fallback.
// Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token.
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
if (conflict) {
return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps)
}
if (error instanceof RuntimeClientError) {
const nextSteps = nextStepsFromData(error.data)
if (nextSteps.length > 0) {
return formatMessageWithNextSteps(message, nextSteps)
}
if (error.code === 'invalid_argument' && context.commandPath?.[0] === 'computer') {
return formatMessageWithNextSteps(
message,
computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? []
)
}
}
if (
error instanceof RuntimeRpcFailureError &&
error.response.error.code === 'runtime_unavailable'
) {
return `${message}\nOrca is not running. Run 'orca open' first.`
}
if (error instanceof RuntimeRpcFailureError) {
return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data))
}
return message
}
function hasOrchestrationRequestId(data: unknown): boolean {
return (
data !== null &&
typeof data === 'object' &&
typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string'
)
}
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
if (json) {
if (error instanceof RuntimeRpcFailureError) {
console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2))
} else {
const response: RuntimeRpcFailure = {
id: 'local',
ok: false,
error: {
code:
matchAutomationOwnerConflict(error) ??
(error instanceof RuntimeClientError ? error.code : 'runtime_error'),
message: stripAutomationOwnerConflictCode(
error instanceof Error ? error.message : String(error)
),
data: localCliErrorData(error, context)
},
_meta: {
runtimeId: null
}
}
console.log(JSON.stringify(response, null, 2))
}
} else {
console.error(formatCliError(error, context))
}
}
/** Machine-readable half of the same recovery the human message carries. */
function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure {
const code = matchAutomationOwnerConflict(response)
const conflict = automationOwnerConflictRecovery(code)
if (!conflict || !code) {
return response
}
return {
...response,
error: {
...response.error,
// Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport.
code,
message: stripAutomationOwnerConflictCode(response.error.message),
data: response.error.data ?? conflict
}
}
}
function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string {
if (nextSteps.length === 0) {
return message
}
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
}
function nextStepsFromData(data: unknown): string[] {
if (
data &&
typeof data === 'object' &&
Array.isArray((data as { nextSteps?: unknown }).nextSteps)
) {
return (data as { nextSteps: unknown[] }).nextSteps.filter(
(step): step is string => typeof step === 'string'
)
}
return []
}
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
// Why: error-specific recovery must win over the generic computer fallback.
if (error instanceof RuntimeClientError && error.data !== undefined) {
return error.data
}
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
if (conflict) {
return conflict
}
if (
error instanceof RuntimeClientError &&
error.code === 'invalid_argument' &&
context.commandPath?.[0] === 'computer'
) {
return computerUseErrorRecoveryData('invalid_argument')
}
return undefined
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as distance from '../shared/edit-distance'
import { suggestCommands, unknownFlagData } from './command-suggestion'
import type { CommandSpec } from './command-spec'
const specs: CommandSpec[] = [
{ path: ['list'], summary: '', usage: '', allowedFlags: [] },
{ path: ['remove'], summary: '', usage: '', allowedFlags: [], destructive: true }
]
afterEach(() => vi.restoreAllMocks())
describe('suggestion distance work', () => {
it('does no distance calculations for a long command, including destructive intent', () => {
const spy = vi.spyOn(distance, 'levenshtein')
expect(suggestCommands(specs, ['x'.repeat(32_768)])).toEqual([])
expect(spy).not.toHaveBeenCalled()
})
it('does no distance calculations for a long flag but still lists valid flags', () => {
const spy = vi.spyOn(distance, 'levenshtein')
expect(unknownFlagData('x'.repeat(32_768), ['worktree', 'json'])).toEqual({
validFlags: ['json', 'worktree'],
suggestions: [],
nextSteps: ['Valid flags: --json, --worktree']
})
expect(spy).not.toHaveBeenCalled()
})
it('keeps the inclusive three-edit suggestion boundary', () => {
expect(suggestCommands(specs, ['listxxx'])).toEqual(['list'])
expect(unknownFlagData('jsonxxx', ['json']).suggestions).toEqual(['json'])
})
it('keeps the inclusive one-edit destructive intent boundary', () => {
expect(suggestCommands(specs, ['remov'])).toEqual(['remove'])
expect(suggestCommands(specs, ['remo'])).toEqual([])
})
it('retains UTF-16 distance semantics at the length boundary', () => {
expect(unknownFlagData('json😀x', ['json']).suggestions).toEqual(['json'])
expect(unknownFlagData('json😀😀', ['json']).suggestions).toEqual([])
})
})
+14 -5
View File
@@ -37,7 +37,10 @@ function destructiveVerbs(specs: CommandSpec[]): Set<string> {
// input token is itself a near-miss of a destructive verb. #6303
function intendsDestruction(inputToken: string, verbs: Set<string>): boolean {
for (const verb of verbs) {
if (levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD) {
if (
Math.abs(inputToken.length - verb.length) <= DESTRUCTIVE_INTENT_THRESHOLD &&
levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD
) {
return true
}
}
@@ -85,7 +88,9 @@ export function suggestCommands(specs: CommandSpec[], commandPath: string[]): st
continue
}
seen.add(joined)
scored.push({ label: joined, distance: levenshtein(input, joined) })
if (Math.abs(input.length - joined.length) <= SUGGESTION_THRESHOLD) {
scored.push({ label: joined, distance: levenshtein(input, joined) })
}
}
}
return rankByDistance(scored)
@@ -106,9 +111,13 @@ export type FlagErrorData = {
}
function suggestFlags(flag: string, validFlags: string[]): string[] {
return rankByDistance(
validFlags.map((candidate) => ({ label: candidate, distance: levenshtein(flag, candidate) }))
)
const scored: { label: string; distance: number }[] = []
for (const candidate of validFlags) {
if (Math.abs(flag.length - candidate.length) <= SUGGESTION_THRESHOLD) {
scored.push({ label: candidate, distance: levenshtein(flag, candidate) })
}
}
return rankByDistance(scored)
}
// Why: include the accepted set so agents can recover without another help call.
+3 -144
View File
@@ -1,13 +1,8 @@
import type { CliStatusResult } from '../shared/runtime-types'
import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
import {
matchAutomationOwnerConflict,
stripAutomationOwnerConflictCode
} from '../shared/automation-owner-conflict'
import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
import { prepareComputerCliJsonResult } from './computer-format'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
import type { RuntimeRpcSuccess } from './runtime-client'
export { formatCliError, reportCliError } from './cli-error'
export {
formatBrowserProfileList,
@@ -67,10 +62,6 @@ export {
formatWorktreeShow
} from './workspace-format'
type CliErrorContext = {
commandPath?: readonly string[]
}
export function printResult<TResult>(
response: RuntimeRpcSuccess<TResult>,
json: boolean,
@@ -83,138 +74,6 @@ export function printResult<TResult>(
console.log(formatter(response.result))
}
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
if (hasOrchestrationRequestId(error.data)) {
return message
}
return `${message}\nOrca is not running. Run 'orca open' first.`
}
// Why: error-specific recovery must win over the generic computer fallback.
// Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token.
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
if (conflict) {
return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps)
}
if (error instanceof RuntimeClientError) {
const nextSteps = nextStepsFromData(error.data)
if (nextSteps.length > 0) {
return formatMessageWithNextSteps(message, nextSteps)
}
if (error.code === 'invalid_argument' && context.commandPath?.[0] === 'computer') {
return formatMessageWithNextSteps(
message,
computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? []
)
}
}
if (
error instanceof RuntimeRpcFailureError &&
error.response.error.code === 'runtime_unavailable'
) {
return `${message}\nOrca is not running. Run 'orca open' first.`
}
if (error instanceof RuntimeRpcFailureError) {
return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data))
}
return message
}
function hasOrchestrationRequestId(data: unknown): boolean {
return (
data !== null &&
typeof data === 'object' &&
typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string'
)
}
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
if (json) {
if (error instanceof RuntimeRpcFailureError) {
console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2))
} else {
const response: RuntimeRpcFailure = {
id: 'local',
ok: false,
error: {
code:
matchAutomationOwnerConflict(error) ??
(error instanceof RuntimeClientError ? error.code : 'runtime_error'),
message: stripAutomationOwnerConflictCode(
error instanceof Error ? error.message : String(error)
),
data: localCliErrorData(error, context)
},
_meta: {
runtimeId: null
}
}
console.log(JSON.stringify(response, null, 2))
}
} else {
console.error(formatCliError(error, context))
}
}
/** Machine-readable half of the same recovery the human message carries. */
function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure {
const code = matchAutomationOwnerConflict(response)
const conflict = automationOwnerConflictRecovery(code)
if (!conflict || !code) {
return response
}
return {
...response,
error: {
...response.error,
// Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport.
code,
message: stripAutomationOwnerConflictCode(response.error.message),
data: response.error.data ?? conflict
}
}
}
function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string {
if (nextSteps.length === 0) {
return message
}
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
}
function nextStepsFromData(data: unknown): string[] {
if (
data &&
typeof data === 'object' &&
Array.isArray((data as { nextSteps?: unknown }).nextSteps)
) {
return (data as { nextSteps: unknown[] }).nextSteps.filter(
(step): step is string => typeof step === 'string'
)
}
return []
}
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
// Why: error-specific recovery must win over the generic computer fallback.
if (error instanceof RuntimeClientError && error.data !== undefined) {
return error.data
}
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
if (conflict) {
return conflict
}
if (
error instanceof RuntimeClientError &&
error.code === 'invalid_argument' &&
context.commandPath?.[0] === 'computer'
) {
return computerUseErrorRecoveryData('invalid_argument')
}
return undefined
}
export type HostListEntry = {
kind: 'local' | 'ssh' | 'environment'
name: string
+1 -1
View File
@@ -15,7 +15,7 @@ import {
resolveHostFlagEnvironmentId
} from './execution-host-flag'
import { listSshTargets } from './host-selector-alternatives'
import { reportCliError } from './format'
import { reportCliError } from './cli-error'
import { printHelp } from './help'
import type { RuntimeClient } from './runtime-client'
import { COMMAND_SPECS } from './specs'
+3 -4
View File
@@ -84,14 +84,12 @@ describe('RuntimeClient module-graph deferral', () => {
process.exitCode = 0
})
// Why: the whole point of the change. These six modules load on EVERY
// invocation, so a value-import of the barrel from any of them drags the
// RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path.
// These eager modules must not pull the RuntimeClient dependency graph into help.
it.each([
'args.ts',
'flags.ts',
'dispatch.ts',
'format.ts',
'cli-error.ts',
'selectors.ts',
'execution-host-flag.ts'
])('%s imports error classes from ./runtime/types, not the barrel', (file) => {
@@ -110,6 +108,7 @@ describe('RuntimeClient module-graph deferral', () => {
expect(source).toContain("import type { RuntimeClient } from './runtime-client'")
expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m)
expect(source).toContain("await import('./runtime-client.js')")
expect(source).toContain("import { reportCliError } from './cli-error'")
})
it('constructs no client for --help', async () => {
+150
View File
@@ -0,0 +1,150 @@
import { EventEmitter } from 'node:events'
import { StringDecoder } from 'node:string_decoder'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMetadata } from '../../shared/runtime-bootstrap'
import { sendRequest } from './transport'
const { createConnection } = vi.hoisted(() => ({ createConnection: vi.fn() }))
vi.mock('node:net', () => ({ createConnection }))
vi.mock('node:crypto', () => ({ randomUUID: () => 'request-1' }))
const metadata: RuntimeMetadata = {
runtimeId: 'runtime-1',
pid: 123,
transports: [{ kind: 'unix', endpoint: 'test-only' }],
authToken: 'token',
startedAt: 1
}
const reply = (result: unknown) =>
`${JSON.stringify({ id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } })}\n`
class TestSocket extends EventEmitter {
setEncoding = vi.fn()
write = vi.fn()
end = vi.fn()
destroy = vi.fn()
}
let socket: TestSocket
beforeEach(() => {
socket = new TestSocket()
createConnection.mockReturnValue(socket)
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('CLI runtime response framing', () => {
it.each([1, 7, 256, 4096])(
'reads a fragmented response with %i-character chunks',
async (size) => {
const result = { data: '界😀'.repeat(10000) }
const encoded = reply(result)
const pending = sendRequest(metadata, 'terminal.read', {}, 30000)
for (let offset = 0; offset < encoded.length; offset += size) {
socket.emit('data', encoded.slice(offset, offset + size))
}
await expect(pending).resolves.toMatchObject({ result })
expect(socket.setEncoding).toHaveBeenCalledExactlyOnceWith('utf8')
expect(socket.end).toHaveBeenCalledOnce()
}
)
it('accepts Unicode split across socket bytes using the existing UTF-8 decoder', async () => {
const pending = sendRequest(metadata, 'terminal.read', {}, 30000)
const decoder = new StringDecoder('utf8')
for (const byte of Buffer.from(reply({ data: '界😀é' }))) {
socket.emit('data', decoder.write(Buffer.from([byte])))
}
socket.emit('data', decoder.end())
await expect(pending).resolves.toMatchObject({ result: { data: '界😀é' } })
})
it('searches each fragment once without rescanning the accumulated reply', async () => {
const encoded = reply({ data: 'x'.repeat(1024 * 1024) })
const pending = sendRequest(metadata, 'terminal.read', {}, 30000)
const originalIndexOf = String.prototype.indexOf
let searchedCharacters = 0
const search = vi
.spyOn(String.prototype, 'indexOf')
.mockImplementation(function (this: string, value, position) {
if (value === '\n') {
searchedCharacters += this.length - (position ?? 0)
}
return originalIndexOf.call(this, value, position)
})
try {
for (let offset = 0; offset < encoded.length; offset += 256) {
socket.emit('data', encoded.slice(offset, offset + 256))
}
} finally {
search.mockRestore()
}
await expect(pending).resolves.toMatchObject({ ok: true })
expect(searchedCharacters).toBe(encoded.length)
})
it('refreshes keepalives across chunks and ignores blanks and data after the final frame', async () => {
vi.useFakeTimers()
const pending = sendRequest(metadata, 'terminal.read', {}, 100)
await vi.advanceTimersByTimeAsync(90)
socket.emit('data', ' \r\n{"_keep')
socket.emit('data', 'alive":true}\n\t\n')
await vi.advanceTimersByTimeAsync(90)
socket.emit('data', `${reply({ data: 'done' })}invalid JSON\n`)
socket.emit('data', 'more ignored data')
await expect(pending).resolves.toMatchObject({ result: { data: 'done' } })
expect(socket.end).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it.each([
['broken JSON\n', 'invalid_runtime_response'],
['{}\n', 'invalid_runtime_response'],
['{"id":"other","ok":true,"result":{}}\n', 'invalid_runtime_response'],
[
'{"id":"request-1","ok":true,"result":{},"_meta":{"runtimeId":"other"}}\n',
'runtime_unavailable'
]
])(
'rejects a fragmented invalid first frame before subsequent valid frames',
async (line, code) => {
const pending = sendRequest(metadata, 'terminal.read', {}, 30000)
socket.emit('data', line.slice(0, 2))
socket.emit('data', line.slice(2) + reply({ data: 'ignored' }))
await expect(pending).rejects.toMatchObject({ code })
expect(socket.end).toHaveBeenCalledOnce()
}
)
it('preserves terminal failure envelopes', async () => {
const pending = sendRequest(metadata, 'terminal.read', {}, 30000)
socket.emit('data', '{"id":"request-1","ok":false,"error":{"code":"bad","message":"no"}}\n')
await expect(pending).resolves.toMatchObject({
ok: false,
error: { code: 'bad', message: 'no' }
})
})
it('rejects close with an incomplete frame and does not parse later data', async () => {
const pending = sendRequest(metadata, 'terminal.read', {}, 30000)
socket.emit('data', '{"id":')
socket.emit('close')
socket.emit('data', reply({ data: 'ignored' }))
await expect(pending).rejects.toMatchObject({ code: 'runtime_unavailable' })
expect(socket.end).toHaveBeenCalledOnce()
})
it('destroys a timed out socket holding an incomplete frame', async () => {
vi.useFakeTimers()
const pending = sendRequest(metadata, 'terminal.read', {}, 100)
const rejected = expect(pending).rejects.toMatchObject({ code: 'runtime_timeout' })
socket.emit('data', '{"id":')
await vi.advanceTimersByTimeAsync(100)
socket.emit('data', reply({ data: 'ignored' }))
await rejected
expect(socket.destroy).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
})
+18 -9
View File
@@ -31,7 +31,7 @@ export async function sendRequest<TResult>(
return
}
const socket = createConnection(transport.endpoint)
let buffer = ''
let lineSegments: string[] = []
let settled = false
const requestId = randomUUID()
@@ -40,6 +40,7 @@ export async function sendRequest<TResult>(
return
}
settled = true
lineSegments = []
socket.destroy()
reject(
new RuntimeClientError(
@@ -56,6 +57,7 @@ export async function sendRequest<TResult>(
return
}
settled = true
lineSegments = []
clearTimeout(timeout)
socket.end()
if (result.ok === false) {
@@ -89,18 +91,27 @@ export async function sendRequest<TResult>(
})
})
socket.on('data', (chunk: string) => {
buffer += chunk
// Why: the server may interleave `{"_keepalive":true}\n` frames with the
// final success/failure frame to keep both idle timers alive during a
// long-poll (see design doc §3.1). Read frames in a loop until we see a
// terminal frame. Each keepalive refreshes the client-side timer so a
// 10 min wait doesn't trip the 60 s default ceiling.
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1 && !settled) {
const line = buffer.slice(0, newlineIndex)
buffer = buffer.slice(newlineIndex + 1)
let cursor = 0
while (cursor < chunk.length && !settled) {
const newlineIndex = chunk.indexOf('\n', cursor)
if (newlineIndex === -1) {
lineSegments.push(chunk.slice(cursor))
return
}
const segment = chunk.slice(cursor, newlineIndex)
let line = segment
if (lineSegments.length > 0) {
lineSegments.push(segment)
line = lineSegments.join('')
lineSegments = []
}
cursor = newlineIndex + 1
if (line.trim().length === 0) {
newlineIndex = buffer.indexOf('\n')
continue
}
@@ -124,7 +135,6 @@ export async function sendRequest<TResult>(
// major). See §7 risk #9.
if (isKeepaliveFrame(raw)) {
timeout.refresh()
newlineIndex = buffer.indexOf('\n')
continue
}
@@ -150,7 +160,6 @@ export async function sendRequest<TResult>(
const frame = parsed.data
if ('_keepalive' in frame) {
timeout.refresh()
newlineIndex = buffer.indexOf('\n')
continue
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { BrowserClientHostCommandEvent } from '../../shared/browser-client-host-protocol'
import {
@@ -102,3 +102,38 @@ describe('readBrowserClientUploadPaths', () => {
)
})
})
it.each([0, 1, 128 * 1024])(
'avoids recopying 16 single-chunk uploads of %i bytes',
async (size) => {
const source = Buffer.alloc(size, 171)
const response = {
contentBase64: source.toString('base64'),
bytesRead: size,
totalBytes: size,
eof: true
}
const remotePaths = Array.from({ length: 16 }, (_, i) => `file-${i}.bin`)
const request = vi.fn(async () => response)
const concat = vi.spyOn(Buffer, 'concat')
let copies = 0
let files: Awaited<ReturnType<typeof fetchBrowserClientUploadFiles>>
try {
files = await fetchBrowserClientUploadFiles({ request, event, remotePaths })
copies = concat.mock.calls.length
} finally {
concat.mockRestore()
}
expect(copies).toBe(0)
expect(request).toHaveBeenCalledTimes(16)
expect(files.map((file) => file.remotePath)).toEqual(remotePaths)
for (const file of files) {
expect(file.contents).toEqual(source)
}
if (size > 0) {
files[0].contents[0] = 0
expect(files[1].contents[0]).toBe(171)
expect(source[0]).toBe(171)
}
}
)
@@ -74,7 +74,7 @@ export async function fetchBrowserClientUploadFiles(options: {
throw new Error('browser_client_upload_transfer_stalled')
}
}
files.push({ remotePath, contents: Buffer.concat(chunks) })
files.push({ remotePath, contents: chunks.length === 1 ? chunks[0] : Buffer.concat(chunks) })
}
return files
}
+30 -8
View File
@@ -5,12 +5,13 @@ import {
mkdtempSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
utimesSync,
writeFileSync
} from 'node:fs'
import os from 'node:os'
import { dirname, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
@@ -141,12 +142,11 @@ describe('buildDaemonHostManifest', () => {
entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js'
})
const byDest = new Map(ops.map((op) => [op.destRel, op]))
// The host exe is renamed to a distinct image name (NOT the source basename)
// so the NSIS updater's name-based `taskkill /IM Orca.exe` can't kill it.
expect(byDest.get('orca-terminal-daemon.exe')?.kind).toBe('file')
expect(byDest.has('Orca.exe')).toBe(false)
// The host exe keeps the source basename: a verbatim, signature-preserving copy with no
// image-name mismatch. What escapes the updater's sweep is the path, not the name.
expect(byDest.get('Orca.exe')?.kind).toBe('file')
const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe')
expect(exeOp?.destRel).not.toBe('Orca.exe')
expect(exeOp?.destRel).toBe('Orca.exe')
// V8/ICU data blobs are read by the Electron bootstrap and kept.
expect(byDest.has('icudtl.dat')).toBe(true)
// GPU/graphics DLLs are never loaded by the windowless host, so not copied.
@@ -170,7 +170,7 @@ describe('materializeRelocatedDaemonHost', () => {
const result = materializeRelocatedDaemonHost()
expect(result).not.toBeNull()
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe'))
expect(result?.execPath).toBe(join(dest, 'Orca.exe'))
expect(result?.entryPath).toBe(
join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
)
@@ -203,6 +203,28 @@ describe('materializeRelocatedDaemonHost', () => {
expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js')
})
it('copies the exe verbatim: same file name and same bytes as the install-dir exe', () => {
const result = materializeRelocatedDaemonHost()
const sourceExe = join(installDir, 'Orca.exe')
// Byte-for-byte under the same name is what preserves the Authenticode signature and leaves
// no renamed-image signal for endpoint detection to read as masquerading.
expect(basename(result!.execPath)).toBe(basename(sourceExe))
expect(readFileSync(result!.execPath)).toEqual(readFileSync(sourceExe))
})
it('tracks a differently-named app exe rather than pinning an image name of its own', () => {
// A dev-channel or rebranded build ships a different executableName; the host copy must follow
// it, which is what keeps the copy verbatim instead of reintroducing a name mismatch.
renameSync(join(installDir, 'Orca.exe'), join(installDir, 'Orca Nightly.exe'))
setProcessProp('execPath', join(installDir, 'Orca Nightly.exe'))
const result = materializeRelocatedDaemonHost()
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
expect(result?.execPath).toBe(join(dest, 'Orca Nightly.exe'))
expect(existsSync(join(dest, 'orca-terminal-daemon.exe'))).toBe(false)
// Re-resolution must agree with materialization or the fork would target a missing exe.
expect(getRelocatedDaemonHost()?.execPath).toBe(join(dest, 'Orca Nightly.exe'))
})
it('is idempotent: a valid marker short-circuits without recopying', () => {
materializeRelocatedDaemonHost()
const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9')
@@ -210,7 +232,7 @@ describe('materializeRelocatedDaemonHost', () => {
const sentinel = join(dest, 'sentinel.txt')
writeFileSync(sentinel, 'keep')
const result = materializeRelocatedDaemonHost()
expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe'))
expect(result?.execPath).toBe(join(dest, 'Orca.exe'))
expect(existsSync(sentinel)).toBe(true)
})
+18 -6
View File
@@ -22,6 +22,10 @@ import { inspectProcessLiveness, mergeProcessLivenessVerdict } from './daemon-pr
* imaged under it, which would otherwise kill the daemon and its live terminals. The relocated exe is a
* run-as-node Orca.exe copy (not node.exe) so there's no console flash and asar still resolves. Fail-open:
* any failure returns null and the caller forks the install-dir host (pre-relocation behavior).
*
* What escapes the updater is the PATH, not the file name: electron-builder's kill sweep selects
* processes whose image path sits under $INSTDIR. See docs/reference/windows-daemon-host-relocation.md
* for the survival contract and why the exe is copied verbatim rather than renamed.
*/
export type RelocatedDaemonHost = {
@@ -37,8 +41,14 @@ const MARKER_NAME = '.materialized.json'
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync.
const LOCAL_HOST_ROOT_NAME = 'Orca'
// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it.
const DAEMON_HOST_EXE_NAME = 'orca-terminal-daemon.exe'
/**
* The host exe keeps the app exe's own file name, so the relocated image is a byte-for-byte,
* name-included copy of a signed binary — nothing for EDR to read as a renamed image (MITRE T1036).
* Survival comes from the path (see the module header). The one name-sensitive updater path is the
* no-PowerShell `taskkill /IM` fallback, where the daemon is killed and terminals cold-restore —
* the documented pre-relocation outcome, not a failure.
*/
const daemonHostExeName = (execPath: string): string => winPath.basename(execPath)
// V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe.
const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']
@@ -146,8 +156,8 @@ export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] {
const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources
const ops: CopyOp[] = []
// Host exe (renamed) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved).
ops.push({ sourcePath: execPath, destRel: DAEMON_HOST_EXE_NAME, kind: 'file' })
// Host exe (verbatim name) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved).
ops.push({ sourcePath: execPath, destRel: daemonHostExeName(execPath), kind: 'file' })
for (const name of RUNTIME_DATA_FILES) {
ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true })
}
@@ -245,7 +255,7 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null {
if (!marker || marker.version !== version) {
return null
}
const execPath = join(dest, DAEMON_HOST_EXE_NAME)
const execPath = join(dest, daemonHostExeName(sources.execPath))
const entryPath = destPath(dest, marker.entryRelPath)
if (!existsSync(execPath) || !existsSync(entryPath)) {
return null
@@ -281,7 +291,9 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null {
entryRelPath: sources.entryRelPath
}
writeFileSync(join(staging, MARKER_NAME), JSON.stringify(marker))
// Replace any stale/partial dest, then publish the staging dir atomically.
// Replace any stale/partial dest, then publish atomically. Windows refuses to delete a running
// image, so a live daemon already hosted in THIS version's dir (same-version reinstall, or a dev
// channel reusing a version) throws here and materialization fails open to the install-dir host.
rmSync(dest, { recursive: true, force: true })
renameSync(staging, dest)
} catch {
+15 -2
View File
@@ -14,7 +14,10 @@ import {
} from '../../../shared/git-status-line-stats-cache'
import { resolveWorktreeHostPath } from '../../../shared/git-metadata-path'
import { gitOptionalLocksDisabledEnv, gitStreamStdout } from '../runner'
import { findExistingWorktreeSymlinkPaths } from '../worktree-symlink-detection'
import {
findExistingWorktreeSymlinkPaths,
getSafeRelativePath
} from '../worktree-symlink-detection'
import type { GetStatusOptions } from './get-status-options'
import { statusReadLeaseOwner } from './git-read-cache-invalidation'
import { detectConflictOperation } from './git-conflict-operation'
@@ -89,8 +92,18 @@ async function dropSharedSymlinkUntrackedEntries(
if (sharedLinkPaths.length === 0 || !entries.some((entry) => entry.area === 'untracked')) {
return
}
const untrackedPaths = new Set(
entries.filter((entry) => entry.area === 'untracked').map((entry) => entry.path)
)
const candidatePaths = sharedLinkPaths.filter((rawPath) => {
const path = getSafeRelativePath(rawPath)
return path.safe && untrackedPaths.has(path.rel)
})
if (candidatePaths.length === 0) {
return
}
const sharedLinks = new Set(
await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths, {
await findExistingWorktreeSymlinkPaths(worktreePath, candidatePaths, {
wslDistro: options.wslDistro
})
)
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path'
import { getStatus } from './source-control/status-read'
const { lstat, stream, conflict } = vi.hoisted(() => ({
lstat: vi.fn(),
stream: vi.fn(),
conflict: vi.fn()
}))
vi.mock('node:fs/promises', () => ({ lstat }))
vi.mock('./source-control/git-conflict-operation', () => ({ detectConflictOperation: conflict }))
vi.mock('./runner', () => ({
gitStreamStdout: stream,
gitOptionalLocksDisabledEnv: () => ({ GIT_OPTIONAL_LOCKS: '0' })
}))
beforeEach(() => {
vi.clearAllMocks()
conflict.mockResolvedValue(undefined)
lstat.mockResolvedValue({ isSymbolicLink: () => true })
stream.mockImplementation(async (_args, options) => {
options.onStdout('? unrelated.txt\n')
return { stoppedEarly: false }
})
})
function status(sharedLinkPaths: string[]) {
return getStatus('/repo', { sharedLinkPaths, includeLineStats: false })
}
describe('status shared symlink probe budget', () => {
it.each([1, 8, 32])(
'does no unrelated symlink probes for %i configured paths over 100 refreshes',
async (count) => {
const paths = Array.from({ length: count }, (_, index) => `shared-${index}`)
for (let refresh = 0; refresh < 100; refresh++) {
expect((await status(paths)).entries).toEqual([
{ path: 'unrelated.txt', status: 'untracked', area: 'untracked' }
])
}
expect(lstat).not.toHaveBeenCalled()
expect(stream).toHaveBeenCalledTimes(100)
}
)
it('probes matching normalized paths, retaining duplicate probes and original order', async () => {
stream.mockImplementation(async (_args, options) => {
options.onStdout('? link\n? 日本 語\n? unrelated.txt\n')
return { stoppedEarly: false }
})
const result = await status(['absent', ' /link ', '\\日本 語', 'link', '../link', 'C:link'])
expect(lstat.mock.calls.map(([path]) => path)).toEqual([
resolve('/repo', 'link'),
resolve('/repo', '日本 語'),
resolve('/repo', 'link')
])
expect(result.entries.map((entry) => entry.path)).toEqual(['unrelated.txt'])
})
it('rechecks matching paths after the filesystem changes and preserves unreadable paths', async () => {
const paths = ['unrelated.txt']
expect((await status(paths)).entries).toEqual([])
lstat.mockResolvedValueOnce({ isSymbolicLink: () => false })
expect((await status(paths)).entries).toHaveLength(1)
lstat.mockRejectedValueOnce(new Error('EACCES'))
expect((await status(paths)).entries).toHaveLength(1)
expect(lstat).toHaveBeenCalledTimes(3)
})
it('does not broaden exact path matching to descendants or case variants', async () => {
stream.mockImplementation(async (_args, options) => {
options.onStdout('? link/child\n? LINK\n')
return { stoppedEarly: false }
})
expect((await status(['link'])).entries.map((entry) => entry.path)).toEqual([
'link/child',
'LINK'
])
expect(lstat).not.toHaveBeenCalled()
})
})
+8
View File
@@ -97,6 +97,14 @@ function makeStore(enabled = true) {
}
}
// These cases exercise foreground behavior against Electron mocks.
beforeEach(() => {
vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined)
vi.stubEnv('ORCA_E2E_HEADLESS', undefined)
vi.stubEnv('ORCA_E2E_HEADFUL', undefined)
})
afterEach(() => vi.unstubAllEnvs())
describe('registerDashboardPopoutHandlers', () => {
let store: ReturnType<typeof makeStore>
@@ -12,6 +12,7 @@ vi.mock('fs/promises', () => ({ stat: statMock }))
vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: subscribeMock }))
import { createLocalWatcher } from './filesystem-watcher-local-events'
import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
@@ -170,6 +171,35 @@ describe('local filesystem watcher flush serialization', () => {
)
})
it('starts no further stats when a full inflight batch is cancelled', async () => {
const eventCount = 5_000
const pendingStats = deferred<{ isDirectory: () => boolean }>()
statMock.mockReturnValue(pendingStats.promise)
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
watcherCallback?.(
null,
Array.from({ length: eventCount }, (_, index) => ({
type: 'update' as const,
path: `/repo/file-${index}.ts`
}))
)
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(8)
cancelLocalBatchFlush(root)
pendingStats.resolve({ isDirectory: () => false })
for (let i = 0; i < eventCount * 4 && root.batch.flushInFlight; i++) {
await Promise.resolve()
}
expect(root.batch.flushInFlight).toBe(false)
expect(statMock).toHaveBeenCalledTimes(8)
expect(sender.send).not.toHaveBeenCalled()
})
it('leaves an open debounce window to the armed timer instead of draining early', async () => {
const firstStat = deferred<{ isDirectory: () => boolean }>()
const secondStat = deferred<{ isDirectory: () => boolean }>()
@@ -139,7 +139,10 @@ async function flushBatch(root: WatchedRoot): Promise<void> {
DIRECTORY_STAT_CONCURRENCY,
async (evt) => {
// Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache.
const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path)
const isDirectory =
root.batch.cancelled || evt.type === 'delete'
? undefined
: await tryStatIsDirectory(evt.path)
return {
kind: evt.type,
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getAllWindowsMock,
@@ -30,6 +30,14 @@ vi.mock('../tray/system-tray', async () =>
import { registerNotificationHandlers } from './notifications'
// These cases exercise foreground behavior against Electron mocks.
beforeEach(() => {
vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined)
vi.stubEnv('ORCA_E2E_HEADLESS', undefined)
vi.stubEnv('ORCA_E2E_HEADFUL', undefined)
})
afterEach(() => vi.unstubAllEnvs())
describe('registerNotificationHandlers', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
_resetAttachmentImageCache,
clearAttachmentImagesForSite,
getCachedAttachmentDataUrl,
loadAttachmentDataUrlWithCache
} from './attachment-image-cache'
type Image = { dataUrl: string; byteSize: number } | null
function deferredImage() {
let resolve!: (image: Image) => void
let reject!: (error: Error) => void
const promise = new Promise<Image>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
beforeEach(_resetAttachmentImageCache)
describe.each(['site', 'all'] as const)('attachment download after clearing %s', (scope) => {
it.each(['success', 'empty', 'failure'] as const)(
'keeps the replacement singleflight when the old download completes with %s',
async (outcome) => {
const old = deferredImage()
const replacement = deferredImage()
let downloads = 0
const load = () => {
downloads += 1
return downloads === 1 ? old.promise : replacement.promise
}
const args = { siteId: 'site-a', attachmentId: 'image-1', load }
const first = loadAttachmentDataUrlWithCache(args).catch(() => 'old failure')
clearAttachmentImagesForSite(scope === 'site' ? 'site-a' : undefined)
const second = loadAttachmentDataUrlWithCache(args)
expect(downloads).toBe(2)
if (outcome === 'failure') {
old.reject(new Error('old failure'))
} else {
old.resolve(outcome === 'empty' ? null : { dataUrl: 'old image', byteSize: 3 })
}
expect(await first).toBe(
outcome === 'failure' ? 'old failure' : outcome === 'empty' ? null : 'old image'
)
expect(getCachedAttachmentDataUrl('site-a', 'image-1')).toBeNull()
const third = loadAttachmentDataUrlWithCache(args)
expect(downloads).toBe(2)
replacement.resolve({ dataUrl: 'new image', byteSize: 3 })
expect(await second).toBe('new image')
expect(await third).toBe('new image')
expect(getCachedAttachmentDataUrl('site-a', 'image-1')).toBe('new image')
}
)
})
+4 -1
View File
@@ -133,7 +133,10 @@ export async function loadAttachmentDataUrlWithCache(args: {
}
return loaded.dataUrl
} finally {
inFlight.delete(key)
// A cleared generation no longer owns the current download's singleflight slot.
if (currentEpoch(args.siteId) === epochAtStart) {
inFlight.delete(key)
}
}
})()
+55 -1
View File
@@ -1,4 +1,6 @@
import { readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import type * as FsPromisesModule from 'node:fs/promises'
import { describe, expect, it, vi } from 'vitest'
import type { ExecutionHostFilesystemRoute } from './providers/execution-host-provider-dispatch'
@@ -136,3 +138,55 @@ describe('detectRepoFileIcon connection boundary', () => {
expect(stat).toHaveBeenCalled()
})
})
describe('declared repo icons through production filesystem routes', () => {
it.each([
['local', false],
['ssh', false],
['local', true],
['ssh', true]
] as const)('preserves declared icon detection on %s (no icon: %s)', async (kind, noIcon) => {
const directory = await mkdtemp(join(tmpdir(), 'orca-icon-href-'))
const source = noIcon
? 'a'.repeat(256 * 1024)
: `${'a'.repeat(32768)}{ rel: "icon", href: "/first.png", href: "/chosen.png" }`
try {
await mkdir(join(directory, 'public'))
await writeFile(join(directory, 'index.html'), source)
await writeFile(join(directory, 'public', 'chosen.png'), Buffer.from(PNG_BASE64, 'base64'))
const provider = remoteFilesystemProvider({
stat: async (path) => {
const info = await stat(path)
return {
type: info.isFile() ? 'file' : 'directory',
size: info.size,
mtime: info.mtimeMs
}
},
readFile: async (path) => {
const buffer = await readFile(path)
const isBinary = path.endsWith('.png')
return {
content: buffer.toString(isBinary ? 'base64' : 'utf8'),
isBinary,
mimeType: isBinary ? 'image/png' : 'text/html'
}
}
})
const route: ExecutionHostFilesystemRoute =
kind === 'local' ? { kind: 'local', hostId: 'local' } : sshRoute('icon-oracle', provider)
await expect(detectRepoFileIcon(directory, route)).resolves.toEqual(
noIcon
? null
: {
type: 'image',
src: `data:image/png;base64,${PNG_BASE64}`,
source: 'file',
label: 'public/chosen.png'
}
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
})
+1 -9
View File
@@ -3,6 +3,7 @@ import { buildImageDataUri } from '../shared/image-data-uri'
import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon'
import type { ExecutionHostFilesystemRoute } from './providers/execution-host-provider-dispatch'
import type { IFilesystemProvider } from './providers/types'
import { extractIconHref } from './repo-icon-source-href'
import { iconHrefCandidates } from './repo-icon-href-candidates'
import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths'
@@ -49,11 +50,6 @@ const REPO_ICON_SOURCE_FILE_CANDIDATES = [
// not read large app entrypoints just to find a small favicon href.
const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024
const LINK_ICON_HTML_RE =
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i
const LINK_ICON_OBJECT_RE =
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i
type DetectedImageFormat = {
mimeType: 'image/png' | 'image/webp'
}
@@ -98,10 +94,6 @@ function detectImageFormat(buffer: Buffer): DetectedImageFormat | null {
return null
}
function extractIconHref(source: string): string | null {
return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null
}
function repoIconFromImageBuffer(buffer: Buffer, relativePath: string): RepoIcon | null {
const format = detectImageFormat(buffer)
if (!format) {
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { extractIconHref } from './repo-icon-source-href'
// Original production expressions are the compatibility oracle.
const HTML_RE =
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i
const OBJECT_RE =
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i
export function originalIconHref(source: string): string | null {
return source.match(HTML_RE)?.[1] ?? source.match(OBJECT_RE)?.[1] ?? null
}
describe('repo icon source href compatibility', () => {
it.each([
'<link <link <link rel="icon" href="last.png">',
'<link rel="icon" href="first.png" href="last.png">',
'<link rel="icon" href="cross>angle.png">',
'<LINK rel="ICON" href="upper.png">',
'<link href="wrong.png"><link rel="icon" href="right.png">',
'',
'plain source without icon properties',
'{ rel: "icon", href: "/first.png", href: "/last.png" }',
'{ href: "/first.png", rel: "icon", rel: "stylesheet" }',
'{ rel: "icon" } { href: "/unrelated.png" }',
'{ rel: "icon", href: "/first.png" } { rel: "icon", href: "/last.png" }',
'{ rel: "icon", href: "/object.png" } <link href="/html.png" rel="icon">',
'{ rel: "ICON", href: "/UPPER.png" }',
'}\n\n{href: "/line.png",\nrel : "shortcut icon"}',
'{ rel: "icon", href: "/unterminated}after brace',
'{ rel: "icon", href: "?query" }',
'{ rel: "icon", href: "/before?query" }',
'<link rel="stylesheet" href="/no.png">',
'<link rel="icon" href="/yes.png"',
'{rel:"icon",href:"/nested{brace}.png"}',
'xrel:"icon", xhref:"/no.png"'
])('preserves original selection for %s', (source) => {
expect(extractIconHref(source)).toBe(originalIconHref(source))
})
it('matches the original across generated malformed property sequences', () => {
const tokens = [
'}',
'{',
' ',
'rel:"icon"',
'href:"a"',
'href:"b"',
'rel:"other"',
'x',
'\n',
'<link ',
'>',
'rel="icon"',
'href="a"',
'href="b"'
]
let seed = 97
for (let sample = 0; sample < 3000; sample++) {
let source = ''
for (let token = 0; token < 12; token++) {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
source += tokens[seed % tokens.length]
}
expect(extractIconHref(source), source).toBe(originalIconHref(source))
}
})
it('handles a maximum-size unterminated HTML tag region', () => {
expect(extractIconHref('<link '.repeat(Math.floor((256 * 1024) / 6)))).toBeNull()
})
it('handles a maximum-size icon-free entrypoint', () => {
expect(extractIconHref('a'.repeat(256 * 1024))).toBeNull()
})
})
+46
View File
@@ -0,0 +1,46 @@
const LINK_START_RE = /<link\b/gi
const LINK_ICON_HTML_RE =
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/iy
const LINK_ICON_OBJECT_RE =
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/iy
function extractHtmlIconHref(source: string): string | null {
LINK_START_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = LINK_START_RE.exec(source))) {
LINK_ICON_HTML_RE.lastIndex = match.index
const href = LINK_ICON_HTML_RE.exec(source)?.[1]
if (href !== undefined) {
return href
}
// Later link starts before the same closing angle see only a subset of these attributes.
const closingAngle = source.indexOf('>', LINK_START_RE.lastIndex)
if (closingAngle === -1) {
return null
}
LINK_START_RE.lastIndex = closingAngle + 1
}
return null
}
export function extractIconHref(source: string): string | null {
const htmlHref = extractHtmlIconHref(source)
if (htmlHref !== null) {
return htmlHref
}
let start = 0
while (start <= source.length) {
// Every suffix before the next closing brace sees the same candidate properties.
LINK_ICON_OBJECT_RE.lastIndex = start
const href = LINK_ICON_OBJECT_RE.exec(source)?.[1]
if (href !== undefined) {
return href
}
const closingBrace = source.indexOf('}', start)
if (closingBrace === -1) {
return null
}
start = closingBrace + 1
}
return null
}
@@ -6,6 +6,37 @@ import {
} from './runtime-mobile-file-path-search'
describe('rankRuntimeMobileFilePaths', () => {
it('preserves basename matching, ordering and total counts for unusual paths', () => {
const paths = [
'',
'/',
'a/',
'a//b.ts',
'b.ts',
'B.TS',
'a\\b.ts',
'界/😀.ts',
'.hidden',
'a/./b.ts'
]
for (const query of ['', ' ', 'b', '.ts', '😀', '/', 'a\\', 'missing']) {
for (const limit of [0, 1, 3, 100]) {
const q = query.trim().toLowerCase()
const prefix = paths.filter((path) => {
const lower = path.toLowerCase()
return lower.startsWith(q) || (lower.split('/').pop() ?? lower).startsWith(q)
})
const other = paths.filter(
(path) => !prefix.includes(path) && path.toLowerCase().includes(q)
)
expect(rankRuntimeMobileFilePaths(paths, query, limit)).toEqual({
paths: [...prefix, ...other].slice(0, limit),
totalCount: prefix.length + other.length
})
}
}
})
it('ranks path and basename prefixes before substrings and caps output', () => {
expect(
rankRuntimeMobileFilePaths(
@@ -76,7 +76,7 @@ export function rankRuntimeMobileFilePaths(
let totalCount = 0
for (const path of paths) {
const lower = path.toLowerCase()
const basename = lower.split('/').pop() ?? lower
const basename = lower.slice(lower.lastIndexOf('/') + 1)
if (lower.startsWith(normalizedQuery) || basename.startsWith(normalizedQuery)) {
totalCount++
if (prefix.length < limit) {
@@ -45,6 +45,34 @@ describe('findSkillFiles', () => {
expect(found).toEqual([join(root, 'near', 'SKILL.md')])
})
it('does not stat directory links beyond the depth bound but still follows in-bound links', async () => {
const base = await makeTree()
const root = join(base, 'skills')
const edge = join(root, 'a', 'b', 'c', 'd')
const target = join(base, 'linked')
await writeFileAt(join(edge, 'SKILL.md'))
await writeFileAt(join(target, 'SKILL.md'))
for (let index = 0; index < 32; index += 1) {
await symlink(
target,
join(edge, `link${index.toString().padStart(2, '0')}`),
process.platform === 'win32' ? 'junction' : 'dir'
)
}
const statPaths: string[] = []
onStat = async (path) => {
statPaths.push(path)
}
expect(await findSkillFiles(root, 4)).toEqual([join(edge, 'SKILL.md')])
expect(statPaths).toEqual([])
expect(await findSkillFiles(root, 5)).toEqual([
join(edge, 'SKILL.md'),
join(edge, 'link00', 'SKILL.md')
])
expect(statPaths).toHaveLength(32)
})
it('returns nothing for a missing root rather than throwing', async () => {
expect(await findSkillFiles(join(await makeTree(), 'absent'), 4)).toEqual([])
})
+9 -5
View File
@@ -43,9 +43,6 @@ export async function findSkillFiles(
// indistinguishable from a genuinely small root, and a caller that cached it
// would publish "these skills no longer exist".
signal?.throwIfAborted()
if (!isWithinDepth(rootPath, dirPath, maxDepth)) {
return
}
let resolvedDirPath: string
try {
resolvedDirPath = await realpath(dirPath)
@@ -61,6 +58,8 @@ export async function findSkillFiles(
if (!entries) {
return
}
// Directory entry names add one segment, so siblings share the depth verdict.
let childrenWithinDepth: boolean | undefined
for (const entry of entries) {
signal?.throwIfAborted()
// Why: a staged sibling sits directly in a scanned root, so without this a
@@ -86,10 +85,15 @@ export async function findSkillFiles(
continue
}
if (entry.isDirectory()) {
await visit(entryPath)
if ((childrenWithinDepth ??= isWithinDepth(rootPath, entryPath, maxDepth))) {
await visit(entryPath)
}
continue
}
if (entry.isSymbolicLink()) {
if (
entry.isSymbolicLink() &&
(childrenWithinDepth ??= isWithinDepth(rootPath, entryPath, maxDepth))
) {
// Why: users commonly symlink agent skill dirs across providers; follow
// directory links but guard by realpath so recursive links cannot loop.
let linksToDirectory = false
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
@@ -34,15 +34,17 @@ describe('ModelManager stream cleanup', () => {
netRequestMock.mockReset()
})
it('removes response progress listeners after a model download finishes', async () => {
it('reuses the idle timer and removes progress listeners after a fragmented download', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout')
try {
const response = new PassThrough() as PassThrough & {
statusCode: number
headers: Record<string, string>
}
response.statusCode = 200
response.headers = { 'content-length': '4' }
response.headers = { 'content-length': '1000' }
const responseHandlers: ((response: unknown) => void)[] = []
const request = {
abort: vi.fn(() => request),
@@ -66,16 +68,26 @@ describe('ModelManager stream cleanup', () => {
const download = manager.downloadFile(
'https://example.com/model.bin',
join(dir, 'model.bin'),
4,
1000,
'm',
() => false
)
response.write(Buffer.from('ab'))
response.end(Buffer.from('cd'))
await vi.advanceTimersByTimeAsync(60_000)
for (let index = 0; index < 1000; index += 1) {
response.write(Buffer.from('a'))
}
await vi.advanceTimersByTimeAsync(119_999)
expect(request.abort).not.toHaveBeenCalled()
response.end()
await expect(download).resolves.toBeUndefined()
expect(response.listenerCount('data')).toBe(0)
expect(vi.getTimerCount()).toBe(0)
expect(readFileSync(join(dir, 'model.bin'), 'utf8')).toBe('a'.repeat(1000))
expect(timeoutSpy.mock.calls.filter(([, delay]) => delay === 120_000)).toHaveLength(1)
} finally {
timeoutSpy.mockRestore()
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
}
})
@@ -71,8 +71,11 @@ export abstract class SpeechModelHttpDownload {
request = null
}
const resetIdleTimeout = (): void => {
clearIdleTimeout()
idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS)
if (idleTimeout) {
idleTimeout.refresh()
} else {
idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS)
}
}
const resolveOnce = (): void => {
if (settled) {
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSshFileStreamInactivityDeadline } from './ssh-file-stream-inactivity-deadline'
import type { SystemPowerLifecycleListener } from '../system-power-lifecycle'
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('SSH file stream inactivity timer', () => {
it('reuses one timer while retaining the deadline of the latest chunk', () => {
vi.useFakeTimers()
const allocate = vi.spyOn(globalThis, 'setTimeout')
const onTimeout = vi.fn()
const unsubscribe = vi.fn()
const deadline = createSshFileStreamInactivityDeadline(onTimeout, (listener) => {
listener.onResume()
return unsubscribe
})
deadline.reset()
vi.advanceTimersByTime(30_000)
for (let chunk = 0; chunk < 1000; chunk += 1) {
deadline.reset()
}
expect(allocate).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(59_999)
expect(onTimeout).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onTimeout).toHaveBeenCalledTimes(1)
deadline.clear()
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it('releases on suspend and creates a fresh timer on resume', () => {
vi.useFakeTimers()
const allocate = vi.spyOn(globalThis, 'setTimeout')
const onTimeout = vi.fn()
let power!: SystemPowerLifecycleListener
const deadline = createSshFileStreamInactivityDeadline(onTimeout, (listener) => {
power = listener
listener.onResume()
return vi.fn()
})
deadline.reset()
vi.advanceTimersByTime(30_000)
power.onSuspend()
for (let chunk = 0; chunk < 1000; chunk += 1) {
deadline.reset()
}
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(120_000)
expect(onTimeout).not.toHaveBeenCalled()
power.onResume()
expect(allocate).toHaveBeenCalledTimes(2)
vi.advanceTimersByTime(59_999)
expect(onTimeout).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onTimeout).toHaveBeenCalledTimes(1)
deadline.clear()
expect(vi.getTimerCount()).toBe(0)
})
it('clears the timer and subscription and supports a later reset', () => {
vi.useFakeTimers()
const onTimeout = vi.fn()
const unsubscribe = vi.fn()
const subscribe = vi.fn((listener: SystemPowerLifecycleListener) => {
listener.onResume()
return unsubscribe
})
const deadline = createSshFileStreamInactivityDeadline(onTimeout, subscribe)
deadline.reset()
deadline.clear()
deadline.clear()
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(120_000)
expect(onTimeout).not.toHaveBeenCalled()
deadline.reset()
expect(subscribe).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(1)
deadline.clear()
expect(unsubscribe).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -24,10 +24,13 @@ export function createSshFileStreamInactivityDeadline(
}
}
const arm = (): void => {
clearTimer()
if (suspended) {
return
}
if (timer) {
timer.refresh()
return
}
timer = setTimeout(onTimeout, SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS)
timer.unref?.()
}
@@ -90,7 +90,10 @@ export function requestGitStreamable(
// killed, but a wedged stream (no frames arriving) rejects instead of
// hanging the caller forever.
const armInactivity = (): void => {
clearInactivity()
if (inactivityTimer) {
inactivityTimer.refresh()
return
}
inactivityTimer = setTimeout(() => {
fail(
new GitResponseStreamError(
@@ -0,0 +1,80 @@
import { expect, it, vi } from 'vitest'
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import { requestGitStreamable } from './ssh-git-response-stream-reader'
it.each(['end', 'abort', 'timeout'] as const)(
'reuses the idle deadline across 1000 chunks and cleans up on %s',
async (finish) => {
vi.useFakeTimers()
const setTimer = vi.spyOn(globalThis, 'setTimeout')
try {
const listeners = new Map<string, (params: Record<string, unknown>) => void>()
const controller = new AbortController()
const content = 'x'.repeat(998)
const encoded = Buffer.from(JSON.stringify(content))
const notify = vi.fn()
const mux = {
request: vi.fn(async () => ({
__orcaGitResponseStream: { streamId: 7, totalBytes: encoded.length, chunkCount: 1000 }
})),
isDisposed: () => false,
notify,
onDispose: () => () => {},
onNotificationByMethod: (
method: string,
callback: (params: Record<string, unknown>) => void
) => {
listeners.set(method, callback)
return () => listeners.delete(method)
}
}
const promise = requestGitStreamable(
mux as unknown as SshChannelMultiplexer,
'git.diff',
{},
{
signal: controller.signal
}
)
const outcome = promise.then(
(value) => ({ value }),
(error: Error) => ({ error: error.message })
)
await vi.advanceTimersByTimeAsync(15_000)
for (let seq = 0; seq < encoded.length; seq++) {
listeners.get('git.responseChunk')!({
streamId: 7,
seq,
data: encoded.subarray(seq, seq + 1).toString('base64')
})
}
await vi.advanceTimersByTimeAsync(29_999)
expect(listeners.size).toBe(3)
expect(notify.mock.calls.filter(([method]) => method === 'git.responseAck')).toHaveLength(
1000
)
const allocations = setTimer.mock.calls.filter(([, delay]) => delay === 30_000).length
if (finish === 'end') {
listeners.get('git.responseEnd')!({ streamId: 7 })
expect(await outcome).toEqual({ value: content })
} else if (finish === 'abort') {
controller.abort()
expect(await outcome).toEqual({ error: 'Request was cancelled' })
} else {
await vi.advanceTimersByTimeAsync(1)
expect(await outcome).toEqual({
error: 'Git response stream stalled (>30000ms without data)'
})
}
expect(allocations).toBe(1)
expect(vi.getTimerCount()).toBe(0)
expect(listeners.size).toBe(0)
expect(
notify.mock.calls.filter(([method]) => method === 'git.cancelResponseStream')
).toHaveLength(finish === 'end' ? 0 : 1)
} finally {
setTimer.mockRestore()
vi.useRealTimers()
}
}
)
+2 -1
View File
@@ -209,6 +209,7 @@ export function waitForSentinel(
const afterSentinelOffset =
sentinelIdx + RELAY_SENTINEL_BUFFER.length - bufferedStdout.length
const afterSentinel = data.subarray(Math.max(0, afterSentinelOffset))
bufferedStdout = Buffer.alloc(0)
if (afterSentinel.length > 0) {
pendingAfterSentinel = afterSentinel
@@ -258,7 +259,7 @@ export function waitForSentinel(
return
}
bufferedStdout = bufferedStdout.length === 0 ? data : Buffer.concat([bufferedStdout, data])
bufferedStdout = startupStdout
})
})
}
@@ -0,0 +1,62 @@
import { EventEmitter } from 'node:events'
import type { ClientChannel } from 'ssh2'
import { expect, it, vi } from 'vitest'
import { RELAY_SENTINEL } from './relay-protocol'
import { waitForSentinel } from './ssh-relay-deploy-helpers'
it.each([1, 256])('copies each startup prefix once across %i chunks', async (chunks) => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
stdin: { write: vi.fn(() => true) },
close: vi.fn(),
pause: vi.fn(),
resume: vi.fn()
})
const pending = waitForSentinel(channel as unknown as ClientChannel)
const chunk = Buffer.alloc((64 * 1024) / chunks, 120)
const concat = vi.spyOn(Buffer, 'concat')
let calls = 0
let copied = 0
try {
for (let i = 0; i < chunks; i++) {
channel.emit('data', chunk)
}
calls = concat.mock.calls.length
copied = concat.mock.calls.reduce(
(sum, [buffers]) => sum + buffers.reduce((bytes, buffer) => bytes + buffer.length, 0),
0
)
} finally {
concat.mockRestore()
}
channel.emit('data', Buffer.from(`${RELAY_SENTINEL}first-frame`))
const transport = await pending
const received: string[] = []
transport.onData((bytes) => received.push(bytes.toString()))
expect(received).toEqual(['first-frame'])
expect(channel.close).not.toHaveBeenCalled()
expect(calls).toBe(chunks - 1)
expect(copied).toBe(chunk.length * ((chunks * (chunks + 1)) / 2 - 1))
})
it.each(Array.from({ length: RELAY_SENTINEL.length + 1 }, (_, i) => i))(
'preserves the marker and binary payload when split at byte %i',
async (split) => {
const channel = Object.assign(new EventEmitter(), {
stderr: new EventEmitter(),
stdin: { write: vi.fn(() => true) },
close: vi.fn()
})
const pending = waitForSentinel(channel as unknown as ClientChannel)
const marker = Buffer.from(RELAY_SENTINEL)
const payload = Buffer.from([0, 255, 128, 10, 13, 1])
channel.emit('data', Buffer.alloc(63 * 1024, 120))
channel.emit('data', marker.subarray(0, split))
channel.emit('data', Buffer.concat([marker.subarray(split), payload]))
const transport = await pending
const received: Buffer[] = []
transport.onData((bytes) => received.push(bytes))
expect(Buffer.concat(received)).toEqual(payload)
expect(channel.close).not.toHaveBeenCalled()
}
)
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', async () =>
(await import('./createMainWindow-test-harness')).electronModuleMock()
@@ -22,6 +22,14 @@ import {
withPlatform
} from './createMainWindow-test-harness'
// These cases exercise foreground behavior against Electron mocks.
beforeEach(() => {
vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined)
vi.stubEnv('ORCA_E2E_HEADLESS', undefined)
vi.stubEnv('ORCA_E2E_HEADFUL', undefined)
})
afterEach(() => vi.unstubAllEnvs())
describe('createMainWindow', () => {
beforeEach(() => {
resetMainWindowMocks()
@@ -171,6 +171,14 @@ function makeStore(ui: Record<string, unknown> = {}): {
const RENDERER_URL = 'http://localhost:5173'
// These cases exercise foreground behavior against Electron mocks.
beforeEach(() => {
vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined)
vi.stubEnv('ORCA_E2E_HEADLESS', undefined)
vi.stubEnv('ORCA_E2E_HEADFUL', undefined)
})
afterEach(() => vi.unstubAllEnvs())
describe('createOrFocusDashboardPopout', () => {
beforeEach(() => {
instances.length = 0
@@ -1,5 +1,5 @@
import type { App, BrowserWindow } from 'electron'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { focusExistingMainWindow } from './focus-existing-window'
type FakeWindowOptions = {
@@ -78,6 +78,12 @@ function makeTimer(): {
}
}
// These cases exercise foreground behavior against Electron mocks.
beforeEach(() => {
vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined)
vi.stubEnv('ORCA_E2E_HEADLESS', undefined)
vi.stubEnv('ORCA_E2E_HEADFUL', undefined)
})
afterEach(() => vi.unstubAllEnvs())
describe('focusExistingMainWindow', () => {
@@ -314,7 +314,19 @@ describe('Agent Map workspace context menu', () => {
clientX: 100,
clientY: 110
})
fireEvent.click(await screen.findByText('Create new worktree for Orca', {}, { timeout: 5_000 }))
const createWorktree = await screen.findByText(
'Create new worktree for Orca',
{},
{ timeout: 5_000 }
)
// Radix restores focus after unmount; drain it before the next test opens a menu.
const focusRestored = new Promise<void>((resolve) => {
screen
.getByRole('menu')
.addEventListener('focusScope.autoFocusOnUnmount', () => resolve(), { once: true })
})
fireEvent.click(createWorktree)
await act(async () => focusRestored)
expect(useAppStore.getState().activeModal).toBe('new-workspace-composer')
expect(useAppStore.getState().modalData).toEqual({
@@ -139,7 +139,7 @@ export function getRichMarkdownAnnotationHighlightRangesForComment(
comment: DiffComment,
markdownSourceLineOffset: number,
// Why optional: callers looping over comments pass one shared build.
prebuiltBlocks?: RichMarkdownCommentBlock[]
prebuiltBlocks?: readonly RichMarkdownCommentBlock[]
): RichMarkdownAnnotationHighlightRange[] {
const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor)
const selectedText = comment.selectedText?.trim()
@@ -192,13 +192,15 @@ export function getRichMarkdownCommentAnchorTop(
block: RichMarkdownCommentBlock,
containerRect: DOMRect,
containerScrollTop: number,
markdownSourceLineOffset: number
markdownSourceLineOffset: number,
prebuiltBlocks?: readonly RichMarkdownCommentBlock[]
): number | null {
try {
const ranges = getRichMarkdownAnnotationHighlightRangesForComment(
editor,
comment,
markdownSourceLineOffset
markdownSourceLineOffset,
prebuiltBlocks
)
// Why: range notes should sort by the start of the selected text. Anchoring
// to the end puts overlapping ranges with the same final line in creation
@@ -1,9 +1,7 @@
import type { Editor } from '@tiptap/react'
import type { DiffComment } from '../../../../shared/diff-comment-types'
import {
buildRichMarkdownCommentBlocks,
getRichMarkdownCommentAnchorTop
} from './rich-markdown-review-annotations'
import { getRichMarkdownCommentAnchorTop } from './rich-markdown-review-annotations'
import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks'
import {
stackRichMarkdownReviewNotePositions,
type RichMarkdownReviewNotePosition
@@ -23,7 +21,7 @@ export function measureRichMarkdownReviewNotePositions({
markdownSourceLineOffset
}: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] {
const containerRect = container.getBoundingClientRect()
const blocks = buildRichMarkdownCommentBlocks(editor)
const blocks = getRichMarkdownReviewRailBlocks(editor)
const nextPositions = markdownComments
.map((comment): RichMarkdownReviewNotePosition | null => {
const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset)
@@ -39,7 +37,8 @@ export function measureRichMarkdownReviewNotePositions({
block,
containerRect,
container.scrollTop,
markdownSourceLineOffset
markdownSourceLineOffset,
blocks
)
return top === null ? null : { comment, top }
})
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
// Run: ORCA_REVIEW_RAIL_BENCH=1 pnpm test src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts
import { expect, it, vi } from 'vitest'
import { Editor as TiptapEditor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning'
// Baseline measurement body from the parent revision, before sharing source blocks.
import type { Editor } from '@tiptap/react'
import type { DiffComment } from '../../../../shared/diff-comment-types'
import {
buildRichMarkdownCommentBlocks,
getRichMarkdownCommentAnchorTop
} from './rich-markdown-review-annotations'
import {
stackRichMarkdownReviewNotePositions,
type RichMarkdownReviewNotePosition
} from './rich-markdown-review-note-layout'
type MeasureRichMarkdownReviewNotePositionsOptions = {
container: HTMLDivElement
editor: Editor
markdownComments: DiffComment[]
markdownSourceLineOffset: number
}
function measureBaseline({
container,
editor,
markdownComments,
markdownSourceLineOffset
}: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] {
const containerRect = container.getBoundingClientRect()
const blocks = buildRichMarkdownCommentBlocks(editor)
const nextPositions = markdownComments
.map((comment): RichMarkdownReviewNotePosition | null => {
const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset)
const block = blocks.find(
(candidate) => candidate.startLine <= bodyLineNumber && bodyLineNumber <= candidate.endLine
)
if (!block) {
return null
}
const top = getRichMarkdownCommentAnchorTop(
editor,
comment,
block,
containerRect,
container.scrollTop,
markdownSourceLineOffset
)
return top === null ? null : { comment, top }
})
.filter((position): position is RichMarkdownReviewNotePosition => position !== null)
return stackRichMarkdownReviewNotePositions(
nextPositions,
measureReviewNoteHeights(container, nextPositions)
)
}
function measureReviewNoteHeights(
container: HTMLDivElement,
positions: RichMarkdownReviewNotePosition[]
): Map<string, number> {
const measuredHeights = new Map<string, number>()
for (const pos of positions) {
const el = container.querySelector(`[data-rich-markdown-review-note-id="${pos.comment.id}"]`)
if (el) {
measuredHeights.set(pos.comment.id, el.getBoundingClientRect().height)
}
}
return measuredHeights
}
it.skipIf(process.env.ORCA_REVIEW_RAIL_BENCH !== '1')(
'benchmarks full review rail measurements',
() => {
for (const blockCount of [250, 1000]) {
const editor = new TiptapEditor({
element: document.createElement('div'),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: {
type: 'doc',
content: Array.from({ length: blockCount }, (_, index) => ({
type: 'paragraph',
content: [{ type: 'text', text: `Paragraph ${index} with reviewable content.` }]
}))
}
})
try {
vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({
top: 100,
bottom: 120,
left: 0,
right: 10
})
const container = document.createElement('div')
const markdownComments: DiffComment[] = Array.from({ length: 5 }, (_, index) => ({
id: `note-${index}`,
worktreeId: 'workspace',
filePath: 'notes.md',
source: 'markdown',
lineNumber: 1 + index * 40,
body: 'Review',
createdAt: index,
side: 'modified'
}))
const args = { editor, container, markdownComments, markdownSourceLineOffset: 0 }
const serialize = vi.spyOn(editor.markdown!, 'serialize')
const baseline = measureBaseline(args)
const baselineCalls = serialize.mock.calls.length
serialize.mockClear()
expect(measureRichMarkdownReviewNotePositions(args)).toEqual(baseline)
const coldCalls = serialize.mock.calls.length
serialize.mockClear()
expect(measureRichMarkdownReviewNotePositions(args)).toEqual(baseline)
const warmCalls = serialize.mock.calls.length
serialize.mockRestore()
const time = (run: () => unknown) => {
const start = performance.now()
for (let index = 0; index < 20; index++) {
run()
}
return (performance.now() - start) / 20
}
const before: number[] = []
const after: number[] = []
for (let round = 0; round < 5; round++) {
before.push(time(() => measureBaseline(args)))
after.push(time(() => measureRichMarkdownReviewNotePositions(args)))
}
const median = (values: number[]) => values.sort((a, b) => a - b)[2]!
const result = {
blockCount,
comments: markdownComments.length,
beforeMs: median(before),
afterMs: median(after),
baselineCalls,
coldCalls,
warmCalls
}
process.stdout.write(`${JSON.stringify(result)}\n`)
expect(baselineCalls).toBe((2 * blockCount - 1) * 6)
expect(coldCalls).toBe(2 * blockCount - 1)
expect(warmCalls).toBe(0)
} finally {
editor.destroy()
vi.restoreAllMocks()
}
}
},
120_000
)
@@ -0,0 +1,139 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor, type JSONContent } from '@tiptap/core'
import type { DiffComment } from '../../../../shared/diff-comment-types'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { buildRichMarkdownCommentBlocks } from './rich-markdown-review-annotations'
import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks'
import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning'
const editors: Editor[] = []
function createEditor(
content: string | JSONContent = '# Heading\n\nFirst paragraph\n\n- One\n- Two'
) {
const editor = new Editor({
element: document.createElement('div'),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content,
...(typeof content === 'string' ? { contentType: 'markdown' as const } : {})
})
editors.push(editor)
// Settle the trailing-node plugin before measuring selection-only transactions.
editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false))
return editor
}
afterEach(() => {
for (const editor of editors.splice(0)) {
editor.destroy()
}
vi.restoreAllMocks()
})
describe('review rail source block reuse', () => {
it.each([
'```ts\nconst value = 1\n\nvalue++\n```\n\nAfter code',
'| First | Second |\n| --- | --- |\n| one | two |\n\nAfter table',
'<details><summary>Toggle</summary>\n\nInside\n\n</details>\n\nAfter toggle'
])('preserves multiline block boundaries for %s', (source) => {
const editor = createEditor(source)
expect(getRichMarkdownReviewRailBlocks(editor)).toEqual(buildRichMarkdownCommentBlocks(editor))
})
it('preserves block lines and reuses them after selection-only transactions', () => {
const editor = createEditor()
const expected = buildRichMarkdownCommentBlocks(editor)
const serialize = vi.spyOn(editor.markdown!, 'serialize')
const blocks = getRichMarkdownReviewRailBlocks(editor)
expect(blocks).toEqual(expected)
expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1)
serialize.mockClear()
editor.commands.setTextSelection(3)
expect(getRichMarkdownReviewRailBlocks(editor)).toBe(blocks)
expect(serialize).not.toHaveBeenCalled()
})
it('rebuilds after edits and undo while keeping editors isolated', () => {
const editor = createEditor()
const original = getRichMarkdownReviewRailBlocks(editor)
editor.commands.insertContentAt(1, 'changed\ntext')
const changed = getRichMarkdownReviewRailBlocks(editor)
expect(changed).not.toBe(original)
expect(changed).toEqual(buildRichMarkdownCommentBlocks(editor))
editor.commands.undo()
expect(getRichMarkdownReviewRailBlocks(editor)).toEqual(original)
expect(getRichMarkdownReviewRailBlocks(createEditor())).not.toBe(original)
})
it('invalidates an in-place serializer replacement and a manager replacement', () => {
const editor = createEditor()
const original = getRichMarkdownReviewRailBlocks(editor)
const serialize = editor.markdown!.serialize.bind(editor.markdown)
vi.spyOn(editor.markdown!, 'serialize').mockImplementation(
(content) => `${serialize(content)}\nextra line`
)
const changed = getRichMarkdownReviewRailBlocks(editor)
expect(changed).not.toEqual(original)
expect(changed).toEqual(buildRichMarkdownCommentBlocks(editor))
editor.markdown = createEditor().markdown
expect(getRichMarkdownReviewRailBlocks(editor)).toEqual(original)
})
it('does not reuse fallback lines after a missing serializer becomes available', () => {
const editor = createEditor()
const markdown = editor.markdown
editor.markdown = undefined
const fallback = getRichMarkdownReviewRailBlocks(editor)
expect(fallback).toEqual(buildRichMarkdownCommentBlocks(editor))
editor.markdown = markdown
expect(getRichMarkdownReviewRailBlocks(editor)).not.toEqual(fallback)
expect(getRichMarkdownReviewRailBlocks(editor)).toEqual(buildRichMarkdownCommentBlocks(editor))
})
it('avoids serialization for every comment and repeated scroll while refreshing geometry', () => {
const editor = createEditor()
let sourceTop = 100
const coords = vi.spyOn(editor.view, 'coordsAtPos').mockImplementation(() => ({
top: sourceTop,
bottom: sourceTop + 20,
left: 0,
right: 10
}))
const container = document.createElement('div')
const comment: DiffComment = {
id: 'note',
worktreeId: 'workspace',
filePath: 'notes.md',
source: 'markdown',
lineNumber: 1,
body: 'Review',
createdAt: 1,
side: 'modified'
}
const markdownComments = Array.from({ length: 5 }, (_, index) => ({
...comment,
id: `note-${index}`,
selectedText: index === 0 ? 'Heading' : undefined
}))
const serialize = vi.spyOn(editor.markdown!, 'serialize')
const measure = () =>
measureRichMarkdownReviewNotePositions({
editor,
container,
markdownComments,
markdownSourceLineOffset: 0
})
expect(measure()[0]?.top).toBe(100)
expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1)
serialize.mockClear()
sourceTop = 200
container.scrollTop = 30
for (let index = 0; index < 60; index++) {
expect(measure()[0]?.top).toBe(230)
}
expect(serialize).not.toHaveBeenCalled()
expect(coords).toHaveBeenCalledTimes(61 * markdownComments.length)
})
})
@@ -0,0 +1,31 @@
import type { Editor } from '@tiptap/core'
import {
buildRichMarkdownCommentBlocks,
type RichMarkdownCommentBlock
} from './rich-markdown-review-annotations'
type ReviewRailBlocks = {
doc: Editor['state']['doc']
markdown: Editor['markdown']
serialize: NonNullable<Editor['markdown']>['serialize'] | undefined
blocks: readonly RichMarkdownCommentBlock[]
}
// Keep only the current document per editor; scrolling changes geometry, not source lines.
const blocksByEditor = new WeakMap<Editor, ReviewRailBlocks>()
export function getRichMarkdownReviewRailBlocks(
editor: Editor
): readonly RichMarkdownCommentBlock[] {
const doc = editor.state.doc
const markdown = editor.markdown
const serialize = markdown?.serialize
const cached = blocksByEditor.get(editor)
if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) {
return cached.blocks
}
const blocks = buildRichMarkdownCommentBlocks(editor)
blocksByEditor.set(editor, { doc, markdown, serialize, blocks })
return blocks
}
@@ -0,0 +1,93 @@
import { Schema } from '@tiptap/pm/model'
import { describe, expect, it, vi } from 'vitest'
import { findRichMarkdownSearchMatches } from './rich-markdown-search'
import { createRichMarkdownSearchMatchesCache } from './rich-markdown-search-matches-cache'
const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: { content: 'text*' },
text: {}
}
})
function createDoc(text = 'Beta beta betas', count = 1) {
return schema.node(
'doc',
null,
Array.from({ length: count }, () => schema.node('paragraph', null, schema.text(text)))
)
}
describe('rich markdown search match reuse', () => {
it('keeps separate live and debounced results without rewalking the document', () => {
const doc = createDoc()
const find = createRichMarkdownSearchMatchesCache()
const walk = vi.spyOn(doc, 'nodesBetween')
const highlighted = find(doc, 'beta')
const live = find(doc, 'betas')
for (let index = 0; index < 100; index++) {
expect(find(doc, 'beta')).toBe(highlighted)
expect(find(doc, 'betas')).toBe(live)
}
expect(walk).toHaveBeenCalledTimes(2)
})
it('keys by document and both matching options', () => {
const find = createRichMarkdownSearchMatchesCache()
const doc = createDoc()
const anyCase = find(doc, 'beta')
expect(find(doc, 'beta', { matchCase: false, wholeWord: false })).toBe(anyCase)
for (const options of [
{ matchCase: true },
{ wholeWord: true },
{ matchCase: true, wholeWord: true }
]) {
expect(find(doc, 'beta', options)).toEqual(
findRichMarkdownSearchMatches(doc, 'beta', options)
)
}
const changedDoc = createDoc('A different beta')
expect(find(changedDoc, 'beta')).toEqual(findRichMarkdownSearchMatches(changedDoc, 'beta'))
expect(find(doc, 'beta')).not.toBe(anyCase)
})
it('bounds retained results to two queries for one document', () => {
const find = createRichMarkdownSearchMatchesCache()
const doc = createDoc()
const first = find(doc, 'beta')
find(doc, 'Beta')
find(doc, 'betas')
expect(find(doc, 'beta')).not.toBe(first)
})
})
it.skipIf(process.env.ORCA_SEARCH_CACHE_BENCH !== '1')(
'benchmarks repeated live-match checks',
() => {
for (const blockCount of [250, 1000]) {
const doc = createDoc('Beta beta betas with searchable content', blockCount)
const find = createRichMarkdownSearchMatchesCache()
const expected = findRichMarkdownSearchMatches(doc, 'beta')
expect(find(doc, 'beta')).toEqual(expected)
const measure = (run: () => unknown) => {
const samples: number[] = []
for (let round = 0; round < 5; round++) {
const start = performance.now()
for (let index = 0; index < 100; index++) {
run()
}
samples.push((performance.now() - start) / 100)
}
return samples.sort((a, b) => a - b)[2]!
}
const beforeMs = measure(() =>
findRichMarkdownSearchMatches(doc, 'beta').some((match) => match.touchesReadOnlyAtom)
)
const afterMs = measure(() => find(doc, 'beta').some((match) => match.touchesReadOnlyAtom))
process.stdout.write(
`${JSON.stringify({ blockCount, matchCount: expected.length, beforeMs, afterMs })}\n`
)
}
}
)
@@ -0,0 +1,36 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { TextMatchOptions } from './markdown-preview-search'
import { findRichMarkdownSearchMatches, type RichMarkdownSearchMatch } from './rich-markdown-search'
type CachedMatches = {
query: string
matchCase: boolean
wholeWord: boolean
matches: RichMarkdownSearchMatch[]
}
export function createRichMarkdownSearchMatchesCache(): typeof findRichMarkdownSearchMatches {
let previousDoc: ProseMirrorNode | undefined
let entries: CachedMatches[] = []
return (doc, query, options: TextMatchOptions = {}, stats) => {
if (doc !== previousDoc) {
previousDoc = doc
entries = []
}
const matchCase = options.matchCase ?? false
const wholeWord = options.wholeWord ?? false
const existing = entries.find(
(entry) =>
entry.query === query && entry.matchCase === matchCase && entry.wholeWord === wholeWord
)
if (existing) {
return existing.matches
}
const matches = findRichMarkdownSearchMatches(doc, query, options, stats)
// The live replacement guard and debounced highlights can have different queries.
entries = [...entries.slice(-1), { query, matchCase, wholeWord, matches }]
return matches
}
}

Some files were not shown because too many files have changed in this diff Show More