mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge origin/main into OrcaWin/win-edr-cmd-shim-resolution
This commit is contained in:
@@ -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
|
||||
"$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 |
@@ -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`.
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user