Merge branch 'stack-reconcile' into stack-final

This commit is contained in:
Neil
2026-09-18 19:29:25 -07:00
1409 changed files with 56331 additions and 3217 deletions
+41
View File
@@ -23,6 +23,9 @@
# runs `git apply` on one must force `-c core.autocrlf=input` rather than trust
# the host's setting. See config/scripts/windows-process-tree-gyp-rebuild.mjs.
/config/patches/*.patch -text
# Same reason, and pnpm parses these too: a CRLF checkout makes the mobile
# patches unparseable, so Windows packaging dies on ERR_PNPM_INVALID_PATCH.
/mobile/patches/*.patch -text
# The xterm bundle hunks also make a diff nobody can read; review the hand-written
# source patch under xterm-src/ instead. The sibling patches stay diffable.
/config/patches/@xterm__xterm@*.patch -diff
@@ -48,3 +51,41 @@
/src/mobile-web/src/*.ts text eol=lf
/src/mobile-web/src/*.css text eol=lf
/src/mobile-web/src/*.png -text
# Mobile web page source. Same buildId hazard as src/mobile-web above: these bytes are
# hashed into the Phase C bundle, so a CRLF Windows checkout would ship a different
# buildId for identical source. web-entry/ does not exist yet; the pin lands ahead of it.
/mobile/src/** text eol=lf
/mobile/app/** text eol=lf
/mobile/web-entry/** text eol=lf
# The blanket pin above would mark a future binary as text; exempt the asset types an
# RN page actually carries, the same way src/mobile-web exempts its PNG.
/mobile/src/**/*.png -text
/mobile/src/**/*.jpg -text
/mobile/src/**/*.jpeg -text
/mobile/src/**/*.gif -text
/mobile/src/**/*.ico -text
/mobile/src/**/*.webp -text
/mobile/src/**/*.ttf -text
/mobile/src/**/*.otf -text
/mobile/src/**/*.woff -text
/mobile/src/**/*.woff2 -text
/mobile/app/**/*.png -text
/mobile/app/**/*.jpg -text
/mobile/app/**/*.jpeg -text
/mobile/app/**/*.gif -text
/mobile/app/**/*.ico -text
/mobile/app/**/*.webp -text
/mobile/app/**/*.ttf -text
/mobile/app/**/*.otf -text
/mobile/app/**/*.woff -text
/mobile/app/**/*.woff2 -text
/mobile/web-entry/**/*.png -text
/mobile/web-entry/**/*.jpg -text
/mobile/web-entry/**/*.jpeg -text
/mobile/web-entry/**/*.gif -text
/mobile/web-entry/**/*.ico -text
/mobile/web-entry/**/*.webp -text
/mobile/web-entry/**/*.ttf -text
/mobile/web-entry/**/*.otf -text
/mobile/web-entry/**/*.woff -text
/mobile/web-entry/**/*.woff2 -text
@@ -0,0 +1,22 @@
name: Install mobile dependencies
description: Frozen pnpm install for the mobile/ project, whose node_modules the mobile web bundle build and the mobile-aware lint passes resolve React Native and Expo from.
runs:
using: composite
steps:
# Why a separate install: mobile is its own pnpm project, so the root install leaves
# mobile/node_modules empty and every mobile import resolves to nothing.
# Why no --ignore-scripts, unlike the root install: mobile's postinstall generates the
# gitignored terminal/mermaid webview engine modules that tracked source imports.
# The drift guard mirrors the root install so a stale mobile lockfile fails by name --
# mobile's lockfile carries patchedDependencies that a silent rewrite would drop.
- name: Install mobile dependencies
shell: bash
working-directory: mobile
run: |
pnpm install --frozen-lockfile
# Job containers can run composite steps from a source mirror without .git.
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
git -C "$GITHUB_WORKSPACE" diff --exit-code -- \
mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml
fi
+7
View File
@@ -184,6 +184,9 @@ jobs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Cache electron-builder downloads
uses: actions/cache@v5
@@ -205,6 +208,10 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
# Why: signing is what makes an adhoc build installable over an existing
# Orca, so a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
+22 -1
View File
@@ -57,7 +57,28 @@ jobs:
uses: actions/cache@v4
with:
path: dist/win-unpacked
key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
# mobile/ is in the key because beforePack requires out/mobile-web, whose bytes come from
# the mobile install and, once Phase C flips the bundle, from the page trees below; a
# mobile-only change must miss this cache, not reuse a stale installer. src/** and
# config/** already cover src/mobile-web and the two bundle builders.
key: >-
win-unpacked-${{ hashFiles(
'src/**',
'config/**',
'package.json',
'pnpm-lock.yaml',
'mobile/package.json',
'mobile/pnpm-lock.yaml',
'mobile/app/**',
'mobile/src/**',
'mobile/web-entry/**'
) }}
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules. Gated with the
# build it feeds, so a cache hit does not pay for an install nothing consumes.
- uses: ./.github/actions/install-mobile-dependencies
if: steps.cache-unpacked.outputs.cache-hit != 'true'
- name: Build unpacked app
if: steps.cache-unpacked.outputs.cache-hit != 'true'
+8
View File
@@ -156,6 +156,9 @@ jobs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
@@ -179,6 +182,11 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
if: steps.freshness.outputs.should_build == 'true'
# Why: signing is what makes a daily installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
@@ -203,6 +203,9 @@ jobs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Caches the Electron binary and electron-builder's tool downloads (nsis,
# winCodeSign). Same key shape as release-cut's Windows leg.
@@ -229,6 +232,10 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
# Why the packaging check runs before the 20-minute build: it only needs
# node_modules, and a stale config should cost seconds rather than a build.
- name: Verify dev-channel packaging identity
+7
View File
@@ -164,6 +164,9 @@ jobs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Cache electron-builder downloads
uses: actions/cache@v5
@@ -185,6 +188,10 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
# Why: signing is what makes an hourly installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
+6
View File
@@ -103,6 +103,12 @@ jobs:
- name: Typecheck tests (ratchet)
run: pnpm run check:tests-typecheck
# This includes the bridged replay of the whole recording corpus, which used to be a second
# step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point:
# it fails when a divergence class grows, when a divergence lands in no class at all, or when
# one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is
# ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite,
# so folding it in costs a fraction of that in wall time and one step less to skip.
- name: Test
run: pnpm test
+83 -17
View File
@@ -29,6 +29,7 @@ jobs:
should_run: ${{ steps.filter.outputs.should_run }}
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }}
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
@@ -142,24 +143,11 @@ jobs:
- name: Enforce type-aware code-quality baseline
run: pnpm run audit:code-quality:type-aware
# Why: the changed-code gate lints mobile files too, and its type-aware pass
# resolves types from mobile/node_modules. Mobile is a separate pnpm project,
# so the root install above leaves it empty and every mobile type degrades to
# an `error` type — reported as phantom findings against the changed lines.
# Why no --ignore-scripts, unlike the root install: mobile's postinstall generates
# the gitignored terminal/mermaid webview engine modules that tracked source imports,
# and skipping it degrades those very types the step exists to resolve. The drift
# guard mirrors the root install so a stale mobile lockfile fails by name — mobile's
# lockfile carries patchedDependencies that a silent rewrite would drop.
- name: Install mobile dependencies
# Why here: the changed-code gate lints mobile files too, and its type-aware pass
# resolves types from mobile/node_modules. Without the install every mobile type
# degrades to an `error` type — reported as phantom findings against the changed lines.
- uses: ./.github/actions/install-mobile-dependencies
if: needs.code_paths.outputs.mobile_dependencies == 'true'
working-directory: mobile
run: |
pnpm install --frozen-lockfile
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
git -C "$GITHUB_WORKSPACE" diff --exit-code -- \
mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml
fi
- name: Enforce changed-code quality
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
@@ -661,6 +649,64 @@ jobs:
pnpm exec vitest run --config config/vitest.config.ts \
src/main/orcad/external-chromium-browser-process.integration.test.ts
# Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test`
# matrix would pay for both on every shard to run two files. Dark through Phase C: this proves
# `build:mobile-web:app` on every PR that touches the page, and ships nothing -- packaging still
# builds the Phase A bootstrap via build:mobile-web.
mobile_web_app:
name: mobile web app bundle
needs: [code_paths]
if: needs.code_paths.outputs.mobile_web_app == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
# Why no native-runtime: the builder is esbuild and the render check is a browser. Nothing
# in this job loads node-pty.
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# The entry lives in mobile/ so one React resolves; without this every RN import is nothing.
- uses: ./.github/actions/install-mobile-dependencies
# Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad
# browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb.
# Why fail instead of skip: a silently skipped render check is the failure this job exists
# to prevent.
- name: Resolve Chrome for the render check
run: |
set -euo pipefail
chrome="$(command -v google-chrome || command -v google-chrome-stable || true)"
if [ -z "$chrome" ]; then
echo "::error::No Google Chrome on the runner; the render check would silently skip."
exit 1
fi
"$chrome" --version
echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV"
- name: Build and verify the app bundle
run: pnpm run build:mobile-web:app
# The bundling tests skip themselves where mobile dependencies are absent, which is how they
# stay green in the sharded `test` job. This is the job that installs them, so here a missing
# install has to fail rather than skip everything the job exists to run.
- name: Builder, override census and render check
env:
ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1'
run: |
pnpm exec vitest run --config config/vitest.config.ts \
config/scripts/build-mobile-web-app-bundle.test.mjs \
config/scripts/mobile-web-app-web-overrides.test.mjs \
config/scripts/mobile-web-app-render.test.mjs
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
@@ -695,6 +741,8 @@ jobs:
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts
tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
managed_hook_node18:
name: managed hooks on Node 18
@@ -746,6 +794,13 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
# Why --no-file-parallelism: every file here launches a full Electron stack twice, and each
# probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other
@@ -859,6 +914,13 @@ jobs:
with:
native-runtime: node
persist-native-cache: 'false'
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
- name: Save compiled Node native modules
if: steps.deps.outputs.native-cache-hit != 'true'
@@ -1017,6 +1079,7 @@ jobs:
- shell_contracts
- test
- orcad_browser
- mobile_web_app
- cross-version-wire
- managed_hook_node18
- package
@@ -1053,6 +1116,8 @@ jobs:
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
MOBILE_WEB_APP: ${{ needs.mobile_web_app.result }}
MOBILE_WEB_APP_SHOULD_RUN: ${{ needs.code_paths.outputs.mobile_web_app }}
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
@@ -1095,6 +1160,7 @@ jobs:
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
check_job test "$TEST" "$TEST_SHOULD_RUN"
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
check_job mobile_web_app "$MOBILE_WEB_APP" "$MOBILE_WEB_APP_SHOULD_RUN"
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
+40 -7
View File
@@ -871,8 +871,17 @@ jobs:
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
# Why: this install runs lifecycle scripts, so node-gyp rebuilds
# native/windows-registry and fetches that Node version's headers from
# nodejs.org. One `read ECONNRESET` there failed this blocking gate and the
# whole cut. Retry like the release build's install below.
- name: Install dependencies
run: pnpm install --frozen-lockfile
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
- name: Build Electron app for platform golden
run: npx electron-vite build --mode e2e
@@ -1088,8 +1097,14 @@ jobs:
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
# Same node-gyp header fetch as the blocking golden gate above.
- name: Install dependencies
run: pnpm install --frozen-lockfile
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
- name: Build Electron app for terminal rendering evidence
run: npx electron-vite build --mode e2e
@@ -1204,22 +1219,33 @@ jobs:
# ref, so cutting from an older/off-main ref whose tree predates a composite
# action would fail the step with "Can't find 'action.yml'". Restore the
# actions directory from the commit this workflow file itself came from.
# Not Windows-only: every platform now consumes install-mobile-dependencies, so
# any of them can be the one whose cut ref predates the action.
- name: Restore composite actions from the workflow ref
if: matrix.platform == 'win' && github.run_attempt == 1
shell: bash
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
action_path=".github/actions/install-signpath-module/action.yml"
if [ -f "$action_path" ]; then
required=(.github/actions/install-mobile-dependencies/action.yml)
if [ "$PLATFORM" = win ] && [ "$GITHUB_RUN_ATTEMPT" = 1 ]; then
required+=(.github/actions/install-signpath-module/action.yml)
fi
missing=()
for action_path in "${required[@]}"; do
[ -f "$action_path" ] || missing+=("$action_path")
done
if [ "${#missing[@]}" -eq 0 ]; then
echo "Composite actions already present at the cut ref."
exit 0
fi
echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA."
echo "Cut ref predates ${missing[*]}; restoring from $WORKFLOW_SHA."
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
git checkout "$WORKFLOW_SHA" -- .github/actions
test -f "$action_path"
for action_path in "${required[@]}"; do
test -f "$action_path"
done
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
- name: Setup pnpm
@@ -1232,6 +1258,9 @@ jobs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Why: release builds hit the same native-module postinstall path as
# PR CI, so keep the pinned node-gyp override here too instead of
@@ -1272,6 +1301,10 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
# Why: `pnpm build:release` verifies the Linux computer-use provider by
# importing AT-SPI bindings, which are runtime package deps but are not
# present on stock GitHub Ubuntu release runners.
+7
View File
@@ -47,6 +47,9 @@ jobs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# Cache the Electron binary + electron-builder tool downloads (notarytool,
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
@@ -74,6 +77,10 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
- name: Verify macOS signing environment
run: node config/scripts/verify-macos-release-env.mjs
env:
+18 -1
View File
@@ -55,6 +55,9 @@ jobs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -67,6 +70,9 @@ jobs:
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
# The mobile page trees are in the key because beforePack builds the mobile web bundle
# into the installer; src/** and config/** already cover src/mobile-web and the two
# bundle builders. A mobile-only change must miss this cache, not reuse a stale exe.
key: >-
crash-survival-installer-${{ hashFiles(
'src/**',
@@ -85,7 +91,12 @@ jobs:
'.npmrc',
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml'
'pnpm-workspace.yaml',
'mobile/package.json',
'mobile/pnpm-lock.yaml',
'mobile/app/**',
'mobile/src/**',
'mobile/web-entry/**'
) }}
# Why: production edits miss the installer cache by design, but Electron
@@ -101,6 +112,12 @@ jobs:
restore-keys: |
crash-survival-electron-builder-
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules. Gated with the
# build it feeds, so a cache hit does not pay for an install nothing consumes.
- uses: ./.github/actions/install-mobile-dependencies
if: steps.cache-installer.outputs.cache-hit != 'true'
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
run: |
@@ -75,6 +75,12 @@ jobs:
path: dist/orca-windows-setup.exe
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules. Gated with the
# build it feeds, so a cache hit does not pay for an install nothing consumes.
- uses: ./.github/actions/install-mobile-dependencies
if: steps.cache-installer.outputs.cache-hit != 'true'
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
run: |
@@ -57,6 +57,9 @@ jobs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Cache electron-builder downloads
uses: actions/cache@v5
@@ -78,6 +81,10 @@ jobs:
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
# build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
# Why: rehearsal builds are never published, so the official-build
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
- name: Build app
+39 -18
View File
@@ -27,10 +27,11 @@ import {
createRegionalRehomeTokenVerifier,
createRuntimeTokenVerifier
} from './admin-token-verifier.js'
import type {
CellFenceAttemptEvidence,
RelayAssignment,
RelayAssignmentStore
import {
RelayHomeCellUnavailableError,
type CellFenceAttemptEvidence,
type RelayAssignment,
type RelayAssignmentStore
} from './assignment-store.js'
import { AssignmentRejectionLogWindow } from './assignment-rejection-log-window.js'
import { CELL_ADMISSION_STATES } from './cell-admission-selector.js'
@@ -361,16 +362,17 @@ export function createRelayApp(
}
}
} catch (error) {
if (isRelayAssignmentCapacityError(error) || isRelayDatabaseTransientError(error)) {
if (isRelayAssignmentUnavailableError(error) || isRelayDatabaseTransientError(error)) {
logAssignmentRejection({
route: 'assign',
lane,
hinted: Boolean(body.data.reconnect),
relayHostId: claims.relayHostId,
reason: operationError(error)
reason: operationError(error),
...homeCellRejectionDetail(error)
})
}
if (isRelayAssignmentCapacityError(error)) {
if (isRelayAssignmentUnavailableError(error)) {
if (lane === 'placement') {
operations.recordRegionSelection?.({ targetRegion, fallback: false })
}
@@ -389,11 +391,13 @@ export function createRelayApp(
fallback: lane === 'placement' && assignment.region !== targetRegion
})
// Grant-side counterpart of the rejection log: reconnect grants are rare
// enough to log and make "which cell is this host on" answerable.
if (lane === 'sticky') {
// enough to log and make "which cell is this host on" answerable. The
// placement-lane ones matter most — they are the only record that a host
// whose sticky lane failed verification landed anywhere at all.
if (body.data.reconnect) {
console.warn(
`[orca-relay] assignment granted lane=sticky host=${relayHostLogDigest(claims.relayHostId)}` +
` cell=${assignment.cellId}`
`[orca-relay] assignment granted lane=${lane} hinted=true` +
` host=${relayHostLogDigest(claims.relayHostId)} cell=${assignment.cellId}`
)
}
const lease = await new SignJWT({
@@ -466,16 +470,17 @@ export function createRelayApp(
leaseExpiresAt: assignment.leaseExpiresAt
})
} catch (error) {
if (isRelayAssignmentCapacityError(error) || isRelayDatabaseTransientError(error)) {
if (isRelayAssignmentUnavailableError(error) || isRelayDatabaseTransientError(error)) {
logAssignmentRejection({
route: 'resolve',
lane: 'none',
hinted: false,
relayHostId: body.data.relayHostId,
reason: operationError(error)
reason: operationError(error),
...homeCellRejectionDetail(error)
})
}
if (isRelayAssignmentCapacityError(error)) {
if (isRelayAssignmentUnavailableError(error)) {
return context.json({ error: operationError(error) }, 503)
}
if (isRelayDatabaseTransientError(error)) return rejectPublicAssignment(context)
@@ -1948,25 +1953,41 @@ function logAssignmentRejection(input: {
hinted: boolean
relayHostId: string
reason: string
cause?: string
cell?: string
suppressed?: number
}): void {
console.warn(
`[orca-relay] assignment rejected route=${input.route} lane=${input.lane}` +
` hinted=${input.hinted} reason=${input.reason}` +
` host=${relayHostLogDigest(input.relayHostId)}` +
(input.cause === undefined ? '' : ` cause=${input.cause}`) +
(input.cell === undefined ? '' : ` cell=${input.cell}`) +
(input.suppressed === undefined ? '' : ` suppressed=${input.suppressed}`)
)
}
function isRelayAssignmentCapacityError(error: unknown): boolean {
// The home-cell reason is not capacity, but it is the same answer to the client:
// retry, the director cannot place you right now.
function isRelayAssignmentUnavailableError(error: unknown): boolean {
return (
error instanceof Error &&
['relay_capacity_exhausted', 'relay_connection_headroom_exhausted'].includes(
error.message
)
[
'relay_capacity_exhausted',
'relay_connection_headroom_exhausted',
'relay_home_cell_unavailable'
].includes(error.message)
)
}
function homeCellRejectionDetail(
error: unknown
): { cause: string; cell: string } | Record<string, never> {
return error instanceof RelayHomeCellUnavailableError
? { cause: error.unavailableCause, cell: error.cellId }
: {}
}
function isCanonicalRelayOrigin(value: string): boolean {
const url = new URL(value)
const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it } from 'vitest'
import { RelayAssignmentStore, RelayHomeCellUnavailableError } from './assignment-store.js'
import type { RelayCellConfig } from './config.js'
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
const HEARTBEAT_TTL_MS = 45_000
const START_MS = 100
const IDENTITY = { userId: 'user-1', relayHostId: 'host000000000001' }
// A connection-limited cell is what makes the committed fence mandatory, and
// that is the branch which used to answer "capacity exhausted".
const FENCED_CELL: RelayCellConfig = {
id: 'home',
url: 'https://home.example.com',
capacityRequests: 1_000,
connectionHardCap: 600,
connectionUnobservedBound: 50
}
const databases: RelayDatabase[] = []
afterEach(async () => {
for (const database of databases.splice(0)) await database.close()
})
interface Harness {
store: RelayAssignmentStore
heartbeat: (cell: RelayCellConfig, ready: boolean) => Promise<void>
setNow: (value: number) => void
}
async function setup(cells: RelayCellConfig[] = [FENCED_CELL]): Promise<Harness> {
const database = await openInMemoryRelayDatabase()
databases.push(database)
let now = START_MS
const store = new RelayAssignmentStore(database, () => now, {
requireLiveCells: true,
heartbeatTtlMs: HEARTBEAT_TTL_MS
})
await store.reconcileCells(cells, true)
const heartbeat = async (cell: RelayCellConfig, ready: boolean): Promise<void> => {
await store.recordCellHeartbeat({
cellId: cell.id,
cellUrl: cell.url,
cellIncarnation: `1111111${cells.indexOf(cell)}-1111-4111-8111-111111111111`,
startedAt: 50,
ready,
observedRequests: 0,
...(cell.connectionHardCap === undefined
? {}
: {
totalConnections: 0,
inFlightConnections: 0,
reservedConnectionUnits: 0,
enforcedConnectionUnits: 0,
connectionHardCap: cell.connectionHardCap,
connectionUnobservedBound: cell.connectionUnobservedBound
})
})
}
for (const cell of cells) await heartbeat(cell, true)
return { store, heartbeat, setNow: (value: number) => (now = value) }
}
async function assignFailure(store: RelayAssignmentStore): Promise<unknown> {
return await store.assign(IDENTITY).then(
() => new Error('assign unexpectedly succeeded'),
(error: unknown) => error
)
}
function homeCellError(error: unknown): RelayHomeCellUnavailableError {
expect(error).toBeInstanceOf(RelayHomeCellUnavailableError)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertion above.
return error as RelayHomeCellUnavailableError
}
describe('home cell unavailable', () => {
it('names a readiness failure rather than reporting fleet capacity', async () => {
const { store, heartbeat, setNow } = await setup()
await store.assign(IDENTITY)
setNow(START_MS + 1_000)
await heartbeat(FENCED_CELL, false)
const error = homeCellError(await assignFailure(store))
expect(error.message).toBe('relay_home_cell_unavailable')
expect(error.unavailableCause).toBe('not_ready')
expect(error.cellId).toBe(FENCED_CELL.id)
})
it('names a heartbeat gap as unheard even though the cell last reported ready', async () => {
const { store, setNow } = await setup()
await store.assign(IDENTITY)
setNow(START_MS + HEARTBEAT_TTL_MS + 1)
expect(homeCellError(await assignFailure(store)).unavailableCause).toBe('unheard')
})
it('names a drained cell as draining ahead of its heartbeat gap', async () => {
const { store, setNow } = await setup()
await store.assign(IDENTITY)
await store.configureCell(FENCED_CELL, false)
setNow(START_MS + HEARTBEAT_TTL_MS + 1)
expect(homeCellError(await assignFailure(store)).unavailableCause).toBe('draining')
})
it('still reports capacity exhaustion when the fleet has no headroom', async () => {
const { store } = await setup([{ ...FENCED_CELL, capacityRequests: 1 }])
await store.assign(IDENTITY)
await expect(
store.assign({ userId: 'user-2', relayHostId: 'host000000000002' })
).rejects.toThrow('relay_capacity_exhausted')
})
it('rehomes instead of rejecting when the unavailable cell needs no fence', async () => {
const home: RelayCellConfig = {
id: 'home',
url: 'https://home.example.com',
capacityRequests: 1_000
}
const spare: RelayCellConfig = {
id: 'spare',
url: 'https://spare.example.com',
capacityRequests: 1_000
}
const { store, heartbeat, setNow } = await setup([home, spare])
expect((await store.assign(IDENTITY)).cellId).toBe(home.id)
setNow(START_MS + 1_000)
await heartbeat(home, false)
expect((await store.assign(IDENTITY)).cellId).toBe(spare.id)
})
})
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RelayAssignment } from './assignment-store.js'
import { RelayHomeCellUnavailableError, type RelayAssignment } from './assignment-store.js'
import type { RelayConfig } from './config.js'
const fakes = vi.hoisted(() => ({
@@ -52,6 +52,39 @@ describe('assignment rejection logging', () => {
expect(line).not.toContain(host)
})
it('separates an unavailable home cell from capacity and names its cause', async () => {
const host = 'cccccccccccccccc'
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const app = createRelayApp(config(), {
store: {} as never,
assignments: {
assign: vi.fn(async () => {
throw new RelayHomeCellUnavailableError('cell-asia-1', 'not_ready')
}),
// The sticky lane refuses a host whose home cell is not live, so this
// arrives hinted on the placement lane.
resolve: vi.fn(async () => null)
} as never,
drain: vi.fn(),
ready: vi.fn(async () => true)
})
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
expect(response.status).toBe(503)
expect(await response.json()).toEqual({ error: 'relay_home_cell_unavailable' })
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
entry.includes('assignment rejected')
)
expect(line).toContain('lane=placement')
expect(line).toContain('hinted=true')
expect(line).toContain('reason=relay_home_cell_unavailable')
expect(line).toContain('cause=not_ready')
expect(line).toContain('cell=cell-asia-1')
expect(line).not.toContain('relay_capacity_exhausted')
expect(line).not.toContain(host)
})
it('logs an unhinted placement rejection without the raw host id', async () => {
const host = 'gggggggggggggggg'
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -221,6 +254,31 @@ describe('assignment grant logging', () => {
expect(line).not.toContain(host)
})
it('logs a hinted grant served by the placement lane', async () => {
const host = 'rrrrrrrrrrrrrrrr'
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const app = createRelayApp(config(), {
store: {} as never,
assignments: {
assign: vi.fn(async () => assignment('cell-new', host)),
resolve: vi.fn(async () => null)
} as never,
drain: vi.fn(),
ready: vi.fn(async () => true)
})
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
expect(response.status).toBe(200)
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
entry.includes('assignment granted')
)
expect(line).toContain('lane=placement')
expect(line).toContain('hinted=true')
expect(line).toContain('cell=cell-new')
expect(line).not.toContain(host)
})
it('does not log unhinted placement grants', async () => {
const host = 'pppppppppppppppp'
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+104 -14
View File
@@ -1,5 +1,13 @@
import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js'
import { IDLE_REHOME_PAGE_SIZE, selectIdleRegionalRehomes } from './idle-regional-rehome-selection.js'
import {
selectIdleRegionalRehomes,
type IdleRegionalRehomeCandidate,
type IdleRehomeHostCursor
} from './idle-regional-rehome-selection.js'
import {
RegionalRehomePollTelemetry,
type RegionalRehomePollGate
} from './regional-rehome-poll-telemetry.js'
import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js'
import {
previewRegionalRehomeEligibility,
@@ -391,6 +399,25 @@ class AssignmentInventoryScopeChanged extends Error {
}
}
// Why a reason of its own: a host whose home cell is fenced-but-unattested is
// refused regardless of fleet headroom, so reporting it as capacity sends
// operators after capacity that was never short. Every cell boot and every
// readiness dip produces these.
export type RelayHomeCellUnavailableCause =
| 'draining'
| 'booting'
| 'unheard'
| 'not_ready'
export class RelayHomeCellUnavailableError extends Error {
constructor(
readonly cellId: string,
readonly unavailableCause: RelayHomeCellUnavailableCause
) {
super('relay_home_cell_unavailable')
}
}
// Debt holds connection headroom for a control that may still arrive shortly
// after its director-side timeout. Nothing legitimately arrives minutes late
// (attach deadline 10s, orphan grace 30s); unretired debt from hosts that
@@ -921,7 +948,10 @@ export class RelayAssignmentStore {
)) &&
!(await this.cellHasCommittedFence(transaction, current.cellId, now))
) {
throw new Error('relay_capacity_exhausted')
throw new RelayHomeCellUnavailableError(
current.cellId,
await this.homeCellUnavailableCause(transaction, current.cellId, now)
)
}
forcedDeadReassignment = true
}
@@ -2673,7 +2703,13 @@ export class RelayAssignmentStore {
)
if (assignment.cellId !== text(row, 'cell_id')) moved++
} catch (error) {
if (!(error instanceof Error && error.message === 'relay_capacity_exhausted')) throw error
// One unplaceable host must not end the sweep for the rest.
if (
!(error instanceof RelayHomeCellUnavailableError) &&
!(error instanceof Error && error.message === 'relay_capacity_exhausted')
) {
throw error
}
}
}
return moved
@@ -3329,28 +3365,57 @@ export class RelayAssignmentStore {
return previewRegionCorrection(this.database, this.now())
}
private idleRegionalCandidateOffset = 0
private idleRegionalCandidateCursor: IdleRehomeHostCursor = null
private readonly regionalRehomePollTelemetry = new RegionalRehomePollTelemetry()
async selectIdleRegionalRehomeCandidates(
processSafety?: RegionalRehomeSafetySnapshot
): Promise<Array<IdleRegionalRehomeRequest & { sourceCellUrl: string }>> {
): Promise<IdleRegionalRehomeCandidate[]> {
const now = this.now()
if (!processSafety || this.regionalRehomeCohortPercent === 0) return []
const gated = (gate: RegionalRehomePollGate): IdleRegionalRehomeCandidate[] => {
this.regionalRehomePollTelemetry.record({ now, gate, candidates: 0 })
return []
}
if (!processSafety) return gated('process-safety-unavailable')
if (this.regionalRehomeCohortPercent === 0) return gated('cohort-zero')
const control = (await this.database.query(
"SELECT enabled, not_before FROM relay_region_rehome_control WHERE control_id = 'global'"
`SELECT enabled, not_before, preference_max_age_ms, host_cooldown_ms
FROM relay_region_rehome_control WHERE control_id = 'global'`
))[0]
if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) return []
if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) {
return gated('control-closed')
}
// The dispatch budget is durable and global, but until now only
// `commitIdleRegionalRehome` consulted it -- after the join had already run and
// the worker had already POSTed every candidate to its source cell. An absent
// row means the budget has never been spent, so it opens the gate.
const worker = (await this.database.query(
`SELECT paused_until, next_dispatch_at FROM relay_region_rehome_worker_state
WHERE worker_id = 'global'`
))[0]
if (worker && (Number(worker.paused_until) > now || Number(worker.next_dispatch_at) > now)) {
return gated('budget-closed')
}
const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now)
if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return []
const candidates = await selectIdleRegionalRehomes({
if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return gated('fleet-safety')
const startedAt = performance.now()
const selection = await selectIdleRegionalRehomes({
database: this.database, now, heartbeatTtlMs: this.heartbeatTtlMs,
cohortPercent: this.regionalRehomeCohortPercent, offset: this.idleRegionalCandidateOffset,
cohortPercent: this.regionalRehomeCohortPercent,
preferenceMaxAgeMs: Number(control.preference_max_age_ms),
hostCooldownMs: Number(control.host_cooldown_ms),
cursor: this.idleRegionalCandidateCursor,
connectionHeadroom: await this.connectionHeadroomByCell(this.database),
cellIsClean: regionalRehomeCellSafetyIsClean
})
this.idleRegionalCandidateOffset = candidates.length < IDLE_REHOME_PAGE_SIZE
? 0 : this.idleRegionalCandidateOffset + candidates.length
return candidates
this.idleRegionalCandidateCursor = selection.cursor
this.regionalRehomePollTelemetry.record({
now,
gate: 'open',
candidates: selection.candidates.length,
selectionMs: performance.now() - startedAt
})
return selection.candidates
}
async commitIdleRegionalRehome(
@@ -7124,6 +7189,31 @@ export class RelayAssignmentStore {
return rows.length === 1
}
// Reports which of `cellIsLive`'s conditions failed, so the rejection log
// separates an expected drain or boot from a cell whose readiness went out
// from under its hosts.
private async homeCellUnavailableCause(
database: RelayDatabase,
cellId: string,
now: number
): Promise<RelayHomeCellUnavailableCause> {
const row = (
await database.query(
`SELECT cell.enabled, runtime.last_heartbeat_at
FROM relay_cells cell
LEFT JOIN relay_cell_runtime runtime ON runtime.cell_id = cell.cell_id
WHERE cell.cell_id = ?`,
[cellId]
)
)[0]
if (!row) return 'booting'
if (integer(row, 'enabled') === 0) return 'draining'
const heartbeatAt = optionalInteger(row, 'last_heartbeat_at')
if (heartbeatAt === undefined) return 'booting'
// Readiness is all that is left: `cellIsLive` already refused this cell.
return heartbeatAt <= now - this.heartbeatTtlMs ? 'unheard' : 'not_ready'
}
private async cellHasActiveFence(cellId: string): Promise<boolean> {
const rows = await this.database.query(
`SELECT fence.cell_id FROM relay_cell_fences fence
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { openRelayDatabaseAtBoot } from './boot-database-open.js'
import type { RelayDatabase } from './database.js'
const input = { dataDir: '/tmp/orca-relay-boot', databaseUrl: 'postgres://relay@localhost/relay' }
// The message the fleet actually saw: pg-pool reports the connect timeout with
// no SQLSTATE, so the classifier has only this text to go on.
const connectTimeout = (): Error => new Error('Connection terminated due to connection timeout')
const database = {} as RelayDatabase
function loggedEvents(warn: MockInstance<typeof console.warn>): string[] {
return warn.mock.calls.map((call) => String(JSON.parse(String(call[0])).event))
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('relay boot database open', () => {
it('waits out a cold proxy instead of failing the boot', async () => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const open = vi
.fn<() => Promise<RelayDatabase>>()
.mockRejectedValueOnce(connectTimeout())
.mockRejectedValueOnce(connectTimeout())
.mockResolvedValue(database)
const opening = openRelayDatabaseAtBoot(input, open)
await vi.runAllTimersAsync()
expect(await opening).toBe(database)
expect(open).toHaveBeenCalledTimes(3)
expect(open).toHaveBeenCalledWith(input)
expect(loggedEvents(warn)).toEqual([
'orca_relay_boot_database_retry',
'orca_relay_boot_database_retry',
'orca_relay_boot_database_recovered'
])
expect(JSON.parse(String(warn.mock.calls[0]?.[0]))).toMatchObject({
attempt: 1,
delayMs: expect.any(Number),
code: 'unknown',
connectionTimeout: true
})
})
it('fails the boot immediately when the database rejects the relay', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const denied = Object.assign(new Error('password authentication failed'), { code: '28P01' })
const open = vi.fn<() => Promise<RelayDatabase>>().mockRejectedValue(denied)
await expect(openRelayDatabaseAtBoot(input, open)).rejects.toBe(denied)
expect(open).toHaveBeenCalledTimes(1)
expect(loggedEvents(warn)).toEqual(['orca_relay_boot_database_failed'])
expect(JSON.parse(String(warn.mock.calls[0]?.[0]))).toMatchObject({
attempts: 1,
retryable: false,
code: 'unknown'
})
})
// A retry re-runs the schema apply, which must never re-queue a boot DDL
// behind the writers that beat it; the request path treats these as transient.
it.each(['55P03', '57014', '53300'])(
'refuses to re-queue the schema apply after SQLSTATE %s',
async (code) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const contention = Object.assign(new Error('lock unavailable'), { code })
const open = vi.fn<() => Promise<RelayDatabase>>().mockRejectedValue(contention)
await expect(openRelayDatabaseAtBoot(input, open)).rejects.toBe(contention)
expect(open).toHaveBeenCalledTimes(1)
expect(loggedEvents(warn)).toEqual(['orca_relay_boot_database_failed'])
expect(JSON.parse(String(warn.mock.calls[0]?.[0]))).toMatchObject({
attempts: 1,
retryable: false,
code
})
}
)
it('waits out a connection failure the driver does report a SQLSTATE for', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const unreachable = Object.assign(new Error('connection refused'), { code: '08006' })
const open = vi
.fn<() => Promise<RelayDatabase>>()
.mockRejectedValueOnce(unreachable)
.mockResolvedValue(database)
const opening = openRelayDatabaseAtBoot(input, open)
await vi.runAllTimersAsync()
expect(await opening).toBe(database)
expect(open).toHaveBeenCalledTimes(2)
})
it('gives up once the retry budget is spent', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
vi.spyOn(Math, 'random').mockReturnValue(0)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const failure = connectTimeout()
const open = vi.fn<() => Promise<RelayDatabase>>().mockRejectedValue(failure)
const opening = openRelayDatabaseAtBoot(input, open)
const rejection = expect(opening).rejects.toBe(failure)
await vi.runAllTimersAsync()
await rejection
expect(Date.now()).toBeLessThanOrEqual(45_000)
expect(open.mock.calls.length).toBeGreaterThan(1)
const events = loggedEvents(warn)
expect(events.at(-1)).toBe('orca_relay_boot_database_failed')
expect(events.filter((event) => event === 'orca_relay_boot_database_retry')).toHaveLength(
open.mock.calls.length - 1
)
expect(JSON.parse(String(warn.mock.calls.at(-1)?.[0]))).toMatchObject({
attempts: open.mock.calls.length,
retryable: true,
connectionTimeout: true
})
})
})
@@ -0,0 +1,71 @@
import { openRelayDatabase, type RelayDatabase, type RelayDatabaseOpenInput } from './database.js'
import { retryTransientDatabaseStartup } from './database-startup-retry.js'
import {
isPostgresPoolConnectFailure,
isPostgresPoolConnectTimeout
} from './postgres-pool-pressure.js'
import { postgresErrorCodeCategory } from './postgres-query-failure.js'
// Only a failure to reach Postgres at all. A retry here re-runs the schema
// apply, and applyPostgresSchema refuses to repeat a DDL lock timeout on
// purpose: relation locks are granted in queue order, so a repeat parks every
// writer behind the same statement again. 55P03, 57014 and 53300 therefore stay
// terminal at boot even though the request path calls them transient.
function isBootDatabaseUnreachable(error: unknown): boolean {
const code = postgresErrorCodeCategory(error)
return isPostgresPoolConnectFailure(error) || code === '08001' || code === '08006'
}
// A cell boots beside a cloud-sql-proxy that is itself still dialling, so the
// first pool acquire can outrun the 2s connect timeout that protects the
// request path. The window is longer than a proxy cold start and shorter than
// the restart loop it replaces.
const BOOT_OPEN_RETRY = {
attempts: 20,
windowMs: 45_000,
baseDelayMs: 250,
maxDelayMs: 4_000,
jitterMs: 250,
isRetryable: isBootDatabaseUnreachable
}
function bootDatabaseErrorFields(error: unknown): Record<string, unknown> {
return {
code: postgresErrorCodeCategory(error),
connectionTimeout: isPostgresPoolConnectTimeout(error)
}
}
export async function openRelayDatabaseAtBoot(
input: RelayDatabaseOpenInput,
open: (input: RelayDatabaseOpenInput) => Promise<RelayDatabase> = openRelayDatabase
): Promise<RelayDatabase> {
return await retryTransientDatabaseStartup(
async () => await open(input),
BOOT_OPEN_RETRY,
{
onRetry: ({ attempt, delayMs, error }) =>
console.warn(
JSON.stringify({
event: 'orca_relay_boot_database_retry',
attempt,
delayMs,
...bootDatabaseErrorFields(error)
})
),
onRecovered: ({ attempts }) =>
console.warn(
JSON.stringify({ event: 'orca_relay_boot_database_recovered', attempts })
),
onGaveUp: ({ attempts, error, retryable }) =>
console.warn(
JSON.stringify({
event: 'orca_relay_boot_database_failed',
attempts,
retryable,
...bootDatabaseErrorFields(error)
})
)
}
)
}
+21 -29
View File
@@ -1,14 +1,19 @@
import type { RelayAssignmentStore } from './assignment-store.js'
import type { RelayConfig } from './config.js'
import { isRelayDatabaseTransientError } from './database.js'
import { retryTransientDatabaseStartup } from './database-startup-retry.js'
type CellAdmissionStartupConfig = Pick<RelayConfig, 'role' | 'cells'>
type CellAdmissionStore = Pick<RelayAssignmentStore, 'reconcileCellsAtStartup'>
const STARTUP_RECONCILE_ATTEMPTS = 20
const STARTUP_RECONCILE_RETRY_WINDOW_MS = 45_000
const STARTUP_RECONCILE_RETRY_BASE_MS = 250
const STARTUP_RECONCILE_RETRY_JITTER_MS = 250
const STARTUP_RECONCILE_RETRY = {
attempts: 20,
windowMs: 45_000,
// Flat: the contention this waits out is another director's schema lock, which
// clears on its own schedule rather than easing as the wait grows.
baseDelayMs: 250,
maxDelayMs: 250,
jitterMs: 250
}
export function roleOwnsAssignmentMaintenance(role: RelayConfig['role']): boolean {
// Cell workers share the database but the director is the sole authority
@@ -23,34 +28,21 @@ export async function reconcileCellAdmissionAtStartup(
// Admission is operator/director state. A new worker must not enable itself
// before its distinct candidate has passed production preflight.
if (config.role === 'cell') return
const retryDeadline = Date.now() + STARTUP_RECONCILE_RETRY_WINDOW_MS
for (let attempt = 1; attempt <= STARTUP_RECONCILE_ATTEMPTS; attempt += 1) {
try {
await assignments.reconcileCellsAtStartup(config.cells)
if (attempt > 1) {
await retryTransientDatabaseStartup(
async () => await assignments.reconcileCellsAtStartup(config.cells),
STARTUP_RECONCILE_RETRY,
{
onRecovered: ({ attempts }) =>
console.warn(
JSON.stringify({ event: 'orca_relay_startup_reconcile_recovered', attempts: attempt })
)
}
return
} catch (error) {
const remainingMs = retryDeadline - Date.now()
if (
attempt === STARTUP_RECONCILE_ATTEMPTS ||
remainingMs <= 0 ||
!isRelayDatabaseTransientError(error)
) {
if (isRelayDatabaseTransientError(error)) {
JSON.stringify({ event: 'orca_relay_startup_reconcile_recovered', attempts })
),
onGaveUp: ({ attempts, retryable }) => {
if (retryable) {
console.warn(
JSON.stringify({ event: 'orca_relay_startup_reconcile_exhausted', attempts: attempt })
JSON.stringify({ event: 'orca_relay_startup_reconcile_exhausted', attempts })
)
}
throw error
}
const delayMs =
STARTUP_RECONCILE_RETRY_BASE_MS +
Math.floor(Math.random() * (STARTUP_RECONCILE_RETRY_JITTER_MS + 1))
await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingMs)))
}
}
)
}
@@ -0,0 +1,52 @@
import { isRelayDatabaseTransientError } from './database.js'
export type DatabaseStartupRetryPolicy = {
attempts: number
windowMs: number
baseDelayMs: number
maxDelayMs: number
jitterMs: number
// Which failures this particular startup step may repeat. Not every caller can
// repeat everything the request path calls transient: what the retry re-runs
// decides that, so the call site owns it.
isRetryable?: (error: unknown) => boolean
}
export type DatabaseStartupRetryObserver = {
onRetry?: (event: { attempt: number; delayMs: number; error: unknown }) => void
onRecovered?: (event: { attempts: number }) => void
onGaveUp?: (event: { attempts: number; error: unknown; retryable: boolean }) => void
}
function retryDelayMs(policy: DatabaseStartupRetryPolicy, attempt: number): number {
const backoffMs = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs)
return backoffMs + Math.floor(Math.random() * (policy.jitterMs + 1))
}
// Startup work that a cold dependency - a proxy sidecar that just started, a
// database still accepting the fleet back - can fail once and serve a moment
// later. The wall-clock window, not the attempt count, is the real bound.
export async function retryTransientDatabaseStartup<T>(
operation: () => Promise<T>,
policy: DatabaseStartupRetryPolicy,
observer: DatabaseStartupRetryObserver = {}
): Promise<T> {
const retryDeadline = Date.now() + policy.windowMs
for (let attempt = 1; ; attempt += 1) {
try {
const result = await operation()
if (attempt > 1) observer.onRecovered?.({ attempts: attempt })
return result
} catch (error) {
const remainingMs = retryDeadline - Date.now()
const retryable = (policy.isRetryable ?? isRelayDatabaseTransientError)(error)
if (attempt === policy.attempts || remainingMs <= 0 || !retryable) {
observer.onGaveUp?.({ attempts: attempt, error, retryable })
throw error
}
const delayMs = Math.min(retryDelayMs(policy, attempt), remainingMs)
observer.onRetry?.({ attempt, delayMs, error })
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
}
}
+4 -2
View File
@@ -1208,13 +1208,15 @@ async function backfillRelayCellRegions(database: RelayDatabase): Promise<void>
)
}
export async function openRelayDatabase(input: {
export type RelayDatabaseOpenInput = {
databaseUrl?: string
dataDir: string
poolMax?: number
applicationName?: string
statementTimeoutMs?: number
}): Promise<RelayDatabase> {
}
export async function openRelayDatabase(input: RelayDatabaseOpenInput): Promise<RelayDatabase> {
let database: RelayDatabase
if (input.databaseUrl) {
await applySchemaOnUntimedPool(input.databaseUrl, input.applicationName)
@@ -4,114 +4,296 @@ import type { RelayDatabase, SqlRow } from './database.js'
export const IDLE_REHOME_PAGE_SIZE = 100
export async function selectIdleRegionalRehomes(input: {
// How many decision rows one poll is allowed to look at. The poll runs about
// fifty times a minute across the directors, so its cost has to be set by this
// number and not by the size of the fleet or the width of the cohort.
export const IDLE_REHOME_DECISION_WINDOW = 500
// Where the last window ended. A keyset beats OFFSET: `OFFSET n` still has to
// produce and throw away n rows, and n grew by a page on every poll that
// dispatched, so the scan got more expensive the longer the rollout ran.
export type IdleRehomeHostCursor = { userId: string; relayHostId: string } | null
export type IdleRegionalRehomeCandidate = IdleRegionalRehomeRequest & { sourceCellUrl: string }
export type IdleRegionalRehomeSelection = {
candidates: IdleRegionalRehomeCandidate[]
cursor: IdleRehomeHostCursor
}
type SourceCell = {
cellId: string
region: string
cellIncarnation: string
startedAt: number
cellUrl: string
}
type TargetCell = { cellId: string; capacityRequests: number; reservedRequests: number }
type SelectionInput = {
database: RelayDatabase
now: number
heartbeatTtlMs: number
cohortPercent: number
offset: number
connectionHeadroom: Map<string, boolean>
preferenceMaxAgeMs: number
hostCooldownMs: number
cursor: IdleRehomeHostCursor
connectionHeadroom: ReadonlyMap<string, boolean>
cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean
}): Promise<Array<IdleRegionalRehomeRequest & { sourceCellUrl: string }>> {
const [runtimes, safetyRows] = await Promise.all([
input.database.query('SELECT * FROM relay_cell_runtime'),
input.database.query('SELECT * FROM relay_cell_rehome_safety')
])
const cleanCells = runtimes
.filter((runtime) =>
input.cellIsClean(
safetyRows.find((safety) => safety.cell_id === runtime.cell_id),
runtime,
input.now
)
)
.map((runtime) => String(runtime.cell_id))
const targetCells = cleanCells.filter((id) => input.connectionHeadroom.get(id) !== false)
if (!cleanCells.length || !targetCells.length) return []
}
const CELL_INVENTORY_QUERY = `SELECT cell.cell_id, cell.cell_url, cell.enabled,
cell.capacity_requests, cell.reserved_requests, region.region,
admission.admission_state, capability.cell_incarnation AS capability_incarnation,
capability.regional_rehome_protocol
FROM relay_cells cell
LEFT JOIN relay_cell_regions region ON region.cell_id = cell.cell_id
LEFT JOIN relay_cell_admission admission ON admission.cell_id = cell.cell_id
LEFT JOIN relay_cell_capabilities capability ON capability.cell_id = cell.cell_id`
export async function selectIdleRegionalRehomes(
input: SelectionInput
): Promise<IdleRegionalRehomeSelection> {
const cells = await readCellInventory(input)
if (!cells.sources.size || !cells.targetsByRegion.size) return { candidates: [], cursor: null }
const sourceRegions = [...new Set([...cells.sources.values()].map((cell) => cell.region))]
const targetRegions = [...cells.targetsByRegion.keys()]
const decisionFilter = `outcome = 'conclusive' AND policy_version = 1
AND preferred_region IN (${placeholders(targetRegions.length)})
AND incumbent_region IN (${placeholders(sourceRegions.length)})
AND preferred_region <> incumbent_region
AND expires_at > ? AND observed_at >= ? AND cohort_bucket < ?`
const decisionParams = [
...targetRegions,
...sourceRegions,
input.now,
input.now - input.preferenceMaxAgeMs,
input.cohortPercent
]
const after = input.cursor ? [input.cursor.userId, input.cursor.relayHostId] : []
const afterFilter = input.cursor ? 'AND (user_id, relay_host_id) > (?, ?)' : ''
// The window is taken first and on its own so the poll knows where it stopped
// reading, not just where it stopped emitting. Every gate below this point can
// reject a host, and a cursor that only advanced past emitted rows would park
// on a rejected host forever.
const window = await input.database.query(
`SELECT user_id, relay_host_id FROM relay_region_decisions
WHERE ${decisionFilter} ${afterFilter}
ORDER BY user_id, relay_host_id LIMIT ?`,
[...decisionParams, ...after, IDLE_REHOME_DECISION_WINDOW]
)
if (!window.length) return { candidates: [], cursor: null }
const windowEnd = window[window.length - 1]!
const windowWasFull = window.length === IDLE_REHOME_DECISION_WINDOW
const sourceList = [...cells.sources.values()]
const rows = await input.database.query(
`SELECT a.user_id, a.relay_host_id, a.cell_id AS source_cell_id,
a.assignment_epoch, host.generation, r.cell_incarnation,
s.cell_url, target.cell_id AS target_cell_id
FROM relay_region_rehome_control policy
JOIN relay_region_decisions d ON d.outcome = 'conclusive'
// The verification names the window's keys rather than repeating its LIMIT:
// the two reads take separate snapshots, and a decision that turned eligible
// between them would otherwise shift the second LIMIT and push the last host
// out of it while the cursor still advanced past it.
`SELECT d.user_id, d.relay_host_id, d.preferred_region, a.cell_id AS source_cell_id,
a.assignment_epoch, host.generation
FROM (SELECT user_id, relay_host_id, preferred_region, incumbent_region, assignment_epoch
FROM relay_region_decisions
WHERE ${decisionFilter}
AND (user_id, relay_host_id) IN (${Array.from({ length: window.length }, () => '(?,?)').join(',')})
-- The LIMIT cannot truncate a key set this size; it is here because without
-- it Postgres flattens the subquery, estimates one row out of the join, and
-- drives the whole plan from a sequential scan of the capability table.
ORDER BY user_id, relay_host_id LIMIT ?) d
JOIN relay_assignments a ON a.user_id = d.user_id AND a.relay_host_id = d.relay_host_id
JOIN relay_cells s ON s.cell_id = a.cell_id AND s.enabled = 1
JOIN relay_cell_regions sr ON sr.cell_id = a.cell_id
JOIN relay_cell_admission sa ON sa.cell_id = a.cell_id AND sa.admission_state = 'general'
JOIN relay_cell_runtime r ON r.cell_id = a.cell_id AND r.ready = 1
JOIN relay_cell_capabilities c ON c.cell_id = r.cell_id AND c.cell_incarnation = r.cell_incarnation
JOIN relay_control_capabilities host ON host.user_id = a.user_id AND host.relay_host_id = a.relay_host_id
AND host.cell_id = a.cell_id AND host.assignment_epoch = a.assignment_epoch
AND host.cell_incarnation = r.cell_incarnation AND host.idle_regional_rehome = 1
JOIN relay_assignment_activity_leases lease ON lease.user_id = host.user_id
AND lease.relay_host_id = host.relay_host_id AND lease.activity_id = host.activity_id
AND a.assignment_epoch = d.assignment_epoch
JOIN (${inlineRows(SOURCE_CELL_COLUMNS, sourceList.length)}) source
ON source.cell_id = a.cell_id AND source.region = d.incumbent_region
JOIN relay_control_capabilities host ON host.user_id = d.user_id
AND host.relay_host_id = d.relay_host_id AND host.cell_id = a.cell_id
AND host.assignment_epoch = a.assignment_epoch
AND host.cell_incarnation = source.cell_incarnation AND host.idle_regional_rehome = 1
JOIN relay_assignment_activity_leases lease ON lease.user_id = d.user_id
AND lease.relay_host_id = d.relay_host_id AND lease.activity_id = host.activity_id
AND lease.cell_id = a.cell_id AND lease.activity_kind = 'control'
JOIN relay_cell_regions tr ON tr.region = d.preferred_region
JOIN relay_cells target ON target.cell_id = tr.cell_id AND target.enabled = 1
JOIN relay_cell_admission ta ON ta.cell_id = target.cell_id AND ta.admission_state = 'general'
JOIN relay_cell_runtime rt ON rt.cell_id = target.cell_id AND rt.ready = 1
JOIN relay_cell_capabilities ct ON ct.cell_id = rt.cell_id AND ct.cell_incarnation = rt.cell_incarnation
WHERE policy.control_id = 'global' AND policy.enabled = 1 AND policy.not_before <= ?
AND d.preferred_region <> sr.region AND d.incumbent_region = sr.region
AND d.assignment_epoch = a.assignment_epoch AND d.policy_version = 1
AND d.expires_at > ? AND d.observed_at >= ? - policy.preference_max_age_ms
AND d.cohort_bucket < ? AND lease.expires_at > ? AND lease.updated_at >= r.started_at
AND r.last_heartbeat_at > ? AND rt.last_heartbeat_at > ?
AND s.cell_id IN (${cleanCells.map(() => '?').join(',')})
AND target.cell_id IN (${targetCells.map(() => '?').join(',')})
-- Reserve the moving host's source activity plus its assignment on the target.
AND target.reserved_requests + 1 + (
SELECT COALESCE(SUM(activity.request_units), 0)
FROM relay_assignment_activity_leases activity
WHERE activity.user_id = a.user_id AND activity.relay_host_id = a.relay_host_id
AND activity.cell_id = a.cell_id
) <= target.capacity_requests
AND c.regional_rehome_protocol >= 3 AND ct.regional_rehome_protocol >= 3
AND NOT EXISTS (SELECT 1 FROM relay_assignment_migrations migration
WHERE migration.user_id = a.user_id AND migration.relay_host_id = a.relay_host_id
AND lease.expires_at > ? AND lease.updated_at >= source.started_at
WHERE NOT EXISTS (SELECT 1 FROM relay_assignment_migrations migration
WHERE migration.user_id = d.user_id AND migration.relay_host_id = d.relay_host_id
AND migration.completed_at IS NULL AND migration.aborted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM relay_region_rehome_attempts attempt
WHERE attempt.user_id = a.user_id AND attempt.relay_host_id = a.relay_host_id
AND attempt.created_at > ? - policy.host_cooldown_ms)
ORDER BY a.user_id, a.relay_host_id, host.generation DESC,
(target.reserved_requests + rt.observed_requests) * 1.0 / target.capacity_requests,
target.cell_id
LIMIT ? OFFSET ?`,
WHERE attempt.user_id = d.user_id AND attempt.relay_host_id = d.relay_host_id
AND attempt.created_at > ?)
ORDER BY d.user_id, d.relay_host_id, host.generation DESC
-- Counted in hosts, because a host with one eligible target has to be able
-- to fill a page on its own. A host with many leaves part of this page
-- unread, and the cursor stops where the page stopped, so it is re-read
-- next poll rather than skipped.
LIMIT ?`,
[
...decisionParams,
...window.flatMap((row) => [row.user_id, row.relay_host_id]),
IDLE_REHOME_DECISION_WINDOW,
...sourceList.flatMap((cell) => [cell.cellId, cell.region, cell.cellIncarnation, cell.startedAt]),
input.now,
input.now,
input.now,
input.cohortPercent,
input.now,
input.now - input.heartbeatTtlMs,
input.now - input.heartbeatTtlMs,
...cleanCells,
...targetCells,
input.now,
IDLE_REHOME_PAGE_SIZE,
input.offset
input.now - input.hostCooldownMs,
IDLE_REHOME_PAGE_SIZE
]
)
return rows.map((row) => {
const request = {
v: 1 as const,
userId: String(row.user_id),
relayHostId: String(row.relay_host_id),
sourceCellId: String(row.source_cell_id),
sourceCellIncarnation: String(row.cell_incarnation),
sourceAssignmentEpoch: Number(row.assignment_epoch),
sourceGeneration: Number(row.generation),
targetCellId: String(row.target_cell_id)
const units = rows.length ? await sourceRequestUnits(input.database, rows) : new Map<string, number>()
const candidates: IdleRegionalRehomeCandidate[] = []
let stoppedAt: IdleRehomeHostCursor = null
for (const row of rows) {
// Whole hosts only: the lower-priority targets are a host's fallbacks when
// the first one defers, and splitting them across pages loses them.
if (candidates.length >= IDLE_REHOME_PAGE_SIZE) {
return { candidates, cursor: stoppedAt }
}
// UUIDv5 keeps retries on every director bound to the same source authority and target.
const digest = createHash('sha1')
.update(Buffer.from('0a1c5a9b197b4ea8b6f1f3bcaa3d712c', 'hex'))
.update(JSON.stringify(request))
.digest()
digest[6] = (digest[6]! & 0x0f) | 0x50
digest[8] = (digest[8]! & 0x3f) | 0x80
const hex = digest.subarray(0, 16).toString('hex')
const attemptId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
return { ...request, attemptId, sourceCellUrl: String(row.cell_url) }
})
const source = cells.sources.get(String(row.source_cell_id))!
const sourceUnits = units.get(hostKey(row)) ?? 0
for (const target of cells.targetsByRegion.get(String(row.preferred_region)) ?? []) {
if (target.reservedRequests + 1 + sourceUnits > target.capacityRequests) continue
candidates.push(idleRehomeCandidate(row, source, target.cellId))
}
stoppedAt = { userId: String(row.user_id), relayHostId: String(row.relay_host_id) }
}
// A full verification page may have been cut short of the window's end, so only
// a page that ran the window out may wrap to the head of the keyspace.
if (rows.length === IDLE_REHOME_PAGE_SIZE) return { candidates, cursor: stoppedAt }
return {
candidates,
cursor: windowWasFull
? { userId: String(windowEnd.user_id), relayHostId: String(windowEnd.relay_host_id) }
: null
}
}
// Every cell predicate the candidate join used to re-evaluate per (host, cell)
// pair. There are tens of cells and tens of thousands of hosts, so this is
// resolved once per poll against the four small inventory tables.
async function readCellInventory(
input: SelectionInput
): Promise<{ sources: Map<string, SourceCell>; targetsByRegion: Map<string, TargetCell[]> }> {
const { database, now } = input
const [runtimeRows, safetyRows, inventory] = await Promise.all([
database.query('SELECT * FROM relay_cell_runtime'),
database.query('SELECT * FROM relay_cell_rehome_safety'),
database.query(CELL_INVENTORY_QUERY)
])
const runtimes = new Map(runtimeRows.map((row) => [String(row.cell_id), row]))
const safety = new Map(safetyRows.map((row) => [String(row.cell_id), row]))
const sources = new Map<string, SourceCell>()
const targetsByRegion = new Map<string, TargetCell[]>()
const load = new Map<string, number>()
for (const cell of inventory) {
const cellId = String(cell.cell_id)
const runtime = runtimes.get(cellId)
if (!runtime || !input.cellIsClean(safety.get(cellId), runtime, now)) continue
if (
Number(cell.enabled) !== 1 ||
cell.admission_state !== 'general' ||
cell.region == null ||
Number(runtime.ready) !== 1 ||
Number(runtime.last_heartbeat_at) <= now - input.heartbeatTtlMs ||
cell.capability_incarnation == null ||
String(cell.capability_incarnation) !== String(runtime.cell_incarnation) ||
Number(cell.regional_rehome_protocol) < 3
) {
continue
}
const region = String(cell.region)
sources.set(cellId, {
cellId,
region,
cellIncarnation: String(runtime.cell_incarnation),
startedAt: Number(runtime.started_at),
cellUrl: String(cell.cell_url)
})
if (input.connectionHeadroom.get(cellId) === false) continue
const capacityRequests = Number(cell.capacity_requests)
const reservedRequests = Number(cell.reserved_requests)
const targets = targetsByRegion.get(region) ?? []
targets.push({ cellId, capacityRequests, reservedRequests })
targetsByRegion.set(region, targets)
load.set(cellId, (reservedRequests + Number(runtime.observed_requests)) / capacityRequests)
}
for (const targets of targetsByRegion.values()) {
targets.sort(
(left, right) =>
load.get(left.cellId)! - load.get(right.cellId)! || (left.cellId < right.cellId ? -1 : 1)
)
}
return { sources, targetsByRegion }
}
// One grouped read for the page instead of a correlated aggregate per (host, cell) pair.
async function sourceRequestUnits(
database: RelayDatabase,
rows: SqlRow[]
): Promise<Map<string, number>> {
const seen = new Set<string>()
const params: unknown[] = []
for (const row of rows) {
if (seen.has(hostKey(row))) continue
seen.add(hostKey(row))
params.push(row.user_id, row.relay_host_id, row.source_cell_id)
}
const sums = await database.query(
`SELECT user_id, relay_host_id, COALESCE(SUM(request_units), 0) AS request_units
FROM relay_assignment_activity_leases
WHERE (user_id, relay_host_id, cell_id) IN (${Array.from({ length: seen.size }, () => '(?,?,?)').join(',')})
GROUP BY user_id, relay_host_id`,
params
)
return new Map(sums.map((row) => [hostKey(row), Number(row.request_units)]))
}
const SOURCE_CELL_COLUMNS = [
['cell_id', 'TEXT'],
['region', 'TEXT'],
['cell_incarnation', 'TEXT'],
['started_at', 'BIGINT']
] as const
function placeholders(count: number): string {
return Array.from({ length: count }, () => '?').join(',')
}
// A derived table the planner can hash, in the one syntax both Postgres and the
// SQLite test engine accept (`VALUES ... AS t(col)` and LATERAL are not common to
// both). Only the first branch is cast; both engines take the union's types from it.
function inlineRows(columns: ReadonlyArray<readonly [string, string]>, rows: number): string {
const first = columns.map(([name, type]) => `CAST(? AS ${type}) AS ${name}`)
const rest = Array.from({ length: rows - 1 }, () => `UNION ALL SELECT ${placeholders(columns.length)}`)
return `SELECT ${first.join(', ')} ${rest.join(' ')}`
}
function hostKey(row: SqlRow): string {
return `${String(row.user_id)}${String(row.relay_host_id)}`
}
function idleRehomeCandidate(
row: SqlRow,
source: SourceCell,
targetCellId: string
): IdleRegionalRehomeCandidate {
const request = {
v: 1 as const,
userId: String(row.user_id),
relayHostId: String(row.relay_host_id),
sourceCellId: source.cellId,
sourceCellIncarnation: source.cellIncarnation,
sourceAssignmentEpoch: Number(row.assignment_epoch),
sourceGeneration: Number(row.generation),
targetCellId
}
// UUIDv5 keeps retries on every director bound to the same source authority and target.
const digest = createHash('sha1')
.update(Buffer.from('0a1c5a9b197b4ea8b6f1f3bcaa3d712c', 'hex'))
.update(JSON.stringify(request))
.digest()
digest[6] = (digest[6]! & 0x0f) | 0x50
digest[8] = (digest[8]! & 0x3f) | 0x80
const hex = digest.subarray(0, 16).toString('hex')
const attemptId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
return { ...request, attemptId, sourceCellUrl: source.cellUrl }
}
@@ -182,6 +182,53 @@ describe('constrained idle regional assignment transaction', () => {
})
})
it.each(['next_dispatch_at', 'paused_until'] as const)(
'skips the candidate join while %s holds the durable dispatch budget closed',
async (column) => {
const { store, database, safety } = await setup()
const query = vi.spyOn(database, 'query')
// One assignment only: setup leaves both fields at 0, and naming the other
// one too would assign this column twice, which Postgres rejects.
await database.query(
`UPDATE relay_region_rehome_worker_state SET ${column} = ? WHERE worker_id = 'global'`,
[safety.observedAt + 1]
)
for (let tick = 0; tick < 3; tick++) {
query.mockClear()
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([])
expect(query).toHaveBeenCalledTimes(2)
expect(query.mock.calls[1]![0]).toMatch(/FROM relay_region_rehome_worker_state/s)
}
await database.query(
`UPDATE relay_region_rehome_worker_state SET ${column} = ? WHERE worker_id = 'global'`,
[safety.observedAt]
)
query.mockClear()
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1)
expect(query.mock.calls.length).toBeGreaterThan(2)
}
)
it('polls when the worker state row has never been written', async () => {
const { store, database, safety } = await setup()
await database.query('DELETE FROM relay_region_rehome_worker_state')
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1)
})
it('leaves the candidate page offset untouched across a closed dispatch budget', async () => {
const { store, database, safety } = await setup()
const first = await store.selectIdleRegionalRehomeCandidates(safety)
await database.query(
`UPDATE relay_region_rehome_worker_state SET next_dispatch_at = ? WHERE worker_id = 'global'`,
[safety.observedAt + 1]
)
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([])
await database.query(
`UPDATE relay_region_rehome_worker_state SET next_dispatch_at = 0 WHERE worker_id = 'global'`
)
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual(first)
})
it('progresses past a full page of busy candidates without writing eligibility state', async () => {
const { store, database, safety } = await setup()
for (const table of [
@@ -0,0 +1,310 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RelayAssignmentStore } from './assignment-store.js'
import type { RelayDatabase } from './database.js'
import { IDLE_REHOME_DECISION_WINDOW } from './idle-regional-rehome-selection.js'
import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js'
// One source cell and three targets, so a poll has to rank targets per host
// rather than take the single one the two-cell fixture leaves it.
const cells = [
{ id: 'us', url: 'https://us.example.test', region: 'us-central1' as const, capacityRequests: 100 },
{ id: 'asia-busy', url: 'https://asia-busy.example.test', region: 'asia-east2' as const, capacityRequests: 100 },
{ id: 'asia-idle', url: 'https://asia-idle.example.test', region: 'asia-east2' as const, capacityRequests: 100 },
{ id: 'asia-mid', url: 'https://asia-mid.example.test', region: 'asia-east2' as const, capacityRequests: 100 }
]
const incarnations = cells.map((_, index) => `${index + 1}${'1'.repeat(7)}-1111-4111-8111-111111111111`)
const observed = [0, 60, 10, 30]
const databases: RelayDatabase[] = []
afterEach(async () => {
vi.restoreAllMocks()
for (const database of databases.splice(0)) await database.close()
})
async function setup() {
const database = await openIdleRehomeTestDatabase()
databases.push(database)
let now = 100_000_000
const store = new RelayAssignmentStore(database, () => now, { regionalRehomeCohortPercent: 100 })
await store.inspectRegionalRehomeControl()
now += 86_400_000
await store.applyRegionalRehomeControl({
expectedGeneration: 0,
enabled: true,
notBefore: now,
ratePerMinute: 10,
preferenceMaxAgeMs: 86_400_000,
hostCooldownMs: 604_800_000,
drainGraceMs: 60_000
})
await store.reconcileCells(cells)
const safety = {
observedAt: now,
sqlFailures: 0,
reconnects: 0,
controlActivityRecoveryFailures: 0,
databasePoolWaiting: 0,
databasePoolWaitersMax: 0,
databasePoolWaitMsMax: 0
}
for (const [index, cell] of cells.entries()) {
await store.recordCellHeartbeat({
cellId: cell.id,
cellUrl: cell.url,
region: cell.region,
cellIncarnation: incarnations[index]!,
startedAt: now - 1_000,
ready: true,
observedRequests: observed[index]!
})
await store.recordCellRegionalRehomeStatus({
cellId: cell.id,
cellIncarnation: incarnations[index]!,
regionalRehomeProtocol: 3,
safety
})
}
return { store, database, safety, now }
}
async function seedHost(
store: RelayAssignmentStore,
identity: { userId: string; relayHostId: string }
): Promise<void> {
const assignment = await store.assign(identity, undefined, 'us-central1')
await store.activateControl(identity, {
cellId: 'us',
assignmentEpoch: assignment.assignmentEpoch,
generation: 7,
cellIncarnation: incarnations[0],
idleRegionalRehome: true
})
const issued = await store.exchangeRegionCorrection(identity, { v: 1, action: 'issue-window' }, assignment.assignmentEpoch)
await store.exchangeRegionCorrection(
identity,
{
v: 1,
action: 'report',
generation: issued.window!.generation,
assignmentEpoch: assignment.assignmentEpoch,
policyVersion: 1,
outcome: 'conclusive',
measurements: { 'us-central1': 180, 'asia-east2': 40 }
},
assignment.assignmentEpoch
)
}
// Clone one seeded host's rows under new identities, which is far cheaper than
// driving the full activation path thousands of times.
async function cloneHosts(
database: RelayDatabase,
template: { userId: string; relayHostId: string },
count: number
): Promise<void> {
for (const table of [
'relay_assignments',
'relay_assignment_activity_leases',
'relay_control_capabilities',
'relay_region_decisions'
]) {
const row = (
await database.query(`SELECT * FROM ${table} WHERE user_id = ? AND relay_host_id = ?`, [
template.userId,
template.relayHostId
])
)[0]!
const columns = Object.keys(row)
const projection = columns.map((column) =>
column === 'user_id' || column === 'relay_host_id' ? '?' : column
)
for (let index = 0; index < count; index++) {
await database.query(
`INSERT INTO ${table} (${columns.join(', ')}) SELECT ${projection.join(', ')} FROM ${table}
WHERE user_id = ? AND relay_host_id = ?`,
[
`clone-${String(index).padStart(5, '0')}`,
`clonehost${String(index).padStart(7, '0')}`,
template.userId,
template.relayHostId
]
)
}
}
}
describe('idle regional rehome candidate window', () => {
const identity = { userId: 'window-test', relayHostId: 'abcdefghijklmnop' }
it('offers every eligible target for a host, least loaded first', async () => {
const { store, safety } = await setup()
await seedHost(store, identity)
const candidates = await store.selectIdleRegionalRehomeCandidates(safety)
expect(candidates.map((candidate) => candidate.targetCellId)).toEqual([
'asia-idle',
'asia-mid',
'asia-busy'
])
expect(new Set(candidates.map((candidate) => candidate.sourceCellUrl))).toEqual(
new Set(['https://us.example.test'])
)
// Every candidate is the same move to a different target, so the attempt ids differ.
expect(new Set(candidates.map((candidate) => candidate.attemptId)).size).toBe(3)
})
it('drops only the targets without room for the host plus its source activity', async () => {
const { store, database, safety } = await setup()
await seedHost(store, identity)
await database.query(
'UPDATE relay_assignment_activity_leases SET request_units = 4 WHERE user_id = ?',
[identity.userId]
)
// Five units needed: four source units plus the assignment the move reserves.
await database.query("UPDATE relay_cells SET capacity_requests = 4 WHERE cell_id = 'asia-idle'")
const short = await store.selectIdleRegionalRehomeCandidates(safety)
expect(short.map((candidate) => candidate.targetCellId)).toEqual(['asia-mid', 'asia-busy'])
// Exactly enough room is enough; it ranks last because the ratio is per capacity.
await database.query("UPDATE relay_cells SET capacity_requests = 5 WHERE cell_id = 'asia-idle'")
const exact = await store.selectIdleRegionalRehomeCandidates(safety)
expect(exact.map((candidate) => candidate.targetCellId)).toEqual([
'asia-mid',
'asia-busy',
'asia-idle'
])
})
it('reads a bounded window of decisions however many hosts are eligible', async () => {
const { store, database, safety } = await setup()
await seedHost(store, identity)
await cloneHosts(database, identity, IDLE_REHOME_DECISION_WINDOW + 200)
const query = vi.spyOn(database, 'query')
await store.selectIdleRegionalRehomeCandidates(safety)
const calls = query.mock.calls.map((call) => call[0])
const window = calls.findIndex((sql) => /FROM relay_region_decisions\s*$/m.test(sql))
expect(window).toBeGreaterThanOrEqual(0)
expect(query.mock.calls[window]![1]!.at(-1)).toBe(IDLE_REHOME_DECISION_WINDOW)
// No statement pages by OFFSET any more: that was the cost that grew with the rollout.
expect(calls.some((sql) => /OFFSET/i.test(sql))).toBe(false)
})
it('keeps the window\'s last host when a decision turns eligible between the two reads', async () => {
const { store, database, safety, now } = await setup()
await seedHost(store, identity)
// Exactly one full window, whose last key in sort order is the seeded host.
await cloneHosts(database, identity, IDLE_REHOME_DECISION_WINDOW - 1)
await database.query(
"UPDATE relay_assignment_activity_leases SET expires_at = ? WHERE user_id LIKE 'clone-%'",
[now - 1]
)
const template = (
await database.query('SELECT * FROM relay_region_decisions WHERE user_id = ?', [
identity.userId
])
)[0]!
const columns = Object.keys(template)
const query = database.query.bind(database)
let inserted = false
vi.spyOn(database, 'query').mockImplementation(async (sql, params) => {
const rows = await query(sql, params)
// A decision that becomes eligible after the window is read and sorts
// inside it: a second LIMIT would push the window's last host out.
if (!inserted && /^SELECT user_id, relay_host_id FROM relay_region_decisions/.test(sql)) {
inserted = true
await query(
`INSERT INTO relay_region_decisions (${columns.join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`,
columns.map((column) =>
column === 'user_id'
? 'clone-99999'
: column === 'relay_host_id'
? 'latehost99999999'
: template[column]
)
)
}
return rows
})
const candidates = await store.selectIdleRegionalRehomeCandidates(safety)
expect(candidates.map((candidate) => candidate.userId)).toEqual([
identity.userId,
identity.userId,
identity.userId
])
})
it('walks the whole population in bounded pages and wraps only at the end', async () => {
const { store, database, safety } = await setup()
await seedHost(store, identity)
await cloneHosts(database, identity, 120)
const seen = new Set<string>()
let pages = 0
let wrapped = false
// 121 hosts x 3 targets is 363 candidates, so the page cap has to be hit
// several times before the window runs out and the cursor wraps.
for (let poll = 0; poll < 20 && !wrapped; poll++) {
const page = await store.selectIdleRegionalRehomeCandidates(safety)
pages += 1
const before = seen.size
for (const candidate of page) seen.add(`${candidate.userId}/${candidate.targetCellId}`)
if (seen.size === before && page.length > 0) wrapped = true
if (page.length < 3) wrapped = true
}
expect(pages).toBeGreaterThan(1)
expect(seen.size).toBe(121 * 3)
})
it('does not stall on a host the window found but the join rejected', async () => {
const { store, database, safety, now } = await setup()
await seedHost(store, identity)
await cloneHosts(database, identity, 2)
// The first host in key order loses its control lease, so it can never be a
// candidate; an emitted-rows cursor would sit on it forever.
await database.query('UPDATE relay_assignment_activity_leases SET expires_at = ? WHERE user_id = ?', [
now - 1,
'clone-00000'
])
const first = await store.selectIdleRegionalRehomeCandidates(safety)
expect(first.map((candidate) => candidate.userId)).not.toContain('clone-00000')
expect(new Set(first.map((candidate) => candidate.userId))).toEqual(
new Set(['clone-00001', identity.userId])
)
})
it('excludes a host inside its rehome cooldown and takes it back after', async () => {
const { store, database, safety, now } = await setup()
await seedHost(store, identity)
await database.query(
`INSERT INTO relay_region_rehome_attempts
(attempt_id, user_id, relay_host_id, preferred_region, source_cell_id, source_cell_incarnation,
target_cell_id, target_cell_incarnation, previous_epoch, assignment_epoch, drain_grace_ms,
send_attempts, created_at, updated_at)
VALUES ('cooled', ?, ?, 'asia-east2', 'us', ?, 'asia-idle', ?, 0, 9, 0, 0, ?, ?)`,
[identity.userId, identity.relayHostId, incarnations[0], incarnations[2], now - 1_000, now]
)
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([])
await database.query('UPDATE relay_region_rehome_attempts SET created_at = ?', [
now - 604_800_000 - 1
])
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(3)
})
it('excludes a host outside the cohort', async () => {
const { store, database } = await setup()
await seedHost(store, identity)
const now = 100_000_000 + 86_400_000
const safety = {
observedAt: now,
sqlFailures: 0,
reconnects: 0,
controlActivityRecoveryFailures: 0,
databasePoolWaiting: 0,
databasePoolWaitersMax: 0,
databasePoolWaitMsMax: 0
}
await database.query('UPDATE relay_region_decisions SET cohort_bucket = 40')
const narrow = new RelayAssignmentStore(database, () => now, { regionalRehomeCohortPercent: 40 })
expect(await narrow.selectIdleRegionalRehomeCandidates(safety)).toEqual([])
const wide = new RelayAssignmentStore(database, () => now, { regionalRehomeCohortPercent: 41 })
expect(await wide.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(3)
})
})
+2 -2
View File
@@ -10,10 +10,10 @@ import {
reconcileCellAdmissionAtStartup,
roleOwnsAssignmentMaintenance
} from './cell-admission-startup.js'
import { openRelayDatabaseAtBoot } from './boot-database-open.js'
import {
consumeRelayCellInventoryHold,
consumeRelayDatabasePoolPressure,
openRelayDatabase,
readRelayDatabasePoolPressure
} from './database.js'
import { runAssignmentCleanup } from './assignment-cleanup-steps.js'
@@ -28,7 +28,7 @@ import {
} from './registered-migration-inventory.js'
const config = loadRelayConfig()
const database = await openRelayDatabase({
const database = await openRelayDatabaseAtBoot({
databaseUrl: config.databaseUrl,
dataDir: config.dataDir,
poolMax: config.databasePoolMax,
+10 -4
View File
@@ -21,6 +21,14 @@ const ERROR_CODES = new Set([
'EPIPE'
])
// A recognised SQLSTATE or errno, or 'unknown': whatever else a driver attached
// to `code` is not a bounded log category.
export function postgresErrorCodeCategory(error: unknown): string {
const code =
typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined
return typeof code === 'string' && ERROR_CODES.has(code) ? code : 'unknown'
}
export function reportPostgresQueryFailure(input: {
error: unknown
phase: QueryFailurePhase
@@ -32,10 +40,8 @@ export function reportPostgresQueryFailure(input: {
}): void {
// Emit only bounded categories: error messages and SQL can contain credentials or identities.
try {
const error = input.error as { code?: unknown; message?: unknown } | null
const code =
typeof error?.code === 'string' && ERROR_CODES.has(error.code) ? error.code : 'unknown'
const connectionTimeout = isPostgresPoolConnectTimeout(error)
const code = postgresErrorCodeCategory(input.error)
const connectionTimeout = isPostgresPoolConnectTimeout(input.error)
console.warn(
JSON.stringify({
event: 'orca_relay_postgres_query_failed',
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS,
RegionalRehomePollTelemetry
} from './regional-rehome-poll-telemetry.js'
describe('regional rehome poll telemetry', () => {
it('names the gate that stopped the poll, not just the empty result', () => {
const lines: string[] = []
const telemetry = new RegionalRehomePollTelemetry((line) => lines.push(line))
let now = 1_000
for (let poll = 0; poll < 3; poll++) {
telemetry.record({ now: (now += 6_000), gate: 'budget-closed', candidates: 0 })
}
telemetry.record({ now: (now += 6_000), gate: 'open', candidates: 0, selectionMs: 12 })
expect(lines).toEqual([])
telemetry.record({
now: now + REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS,
gate: 'open',
candidates: 7,
selectionMs: 30
})
expect(lines).toHaveLength(1)
expect(JSON.parse(lines[0]!)).toMatchObject({
event: 'orca_relay_regional_rehome_poll_summary',
polls: 5,
'budget-closed': 3,
open: 2,
candidates: 7,
selectionMsMax: 30
})
})
it('starts a fresh window after each summary', () => {
const lines: string[] = []
const telemetry = new RegionalRehomePollTelemetry((line) => lines.push(line))
telemetry.record({ now: 0, gate: 'control-closed', candidates: 0 })
telemetry.record({
now: REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS,
gate: 'control-closed',
candidates: 0
})
telemetry.record({
now: REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS * 2,
gate: 'fleet-safety',
candidates: 0
})
expect(lines).toHaveLength(2)
expect(JSON.parse(lines[1]!)).toMatchObject({
polls: 1,
'control-closed': 0,
'fleet-safety': 1,
candidates: 0,
selectionMsMax: 0,
selectionMsP95: 0
})
})
})
@@ -0,0 +1,69 @@
// A gated poll and a poll that simply found nobody to move both produce zero
// candidates and no attempt row, so an operator watching a stalled rollout
// cannot tell them apart. One aggregated line a minute per director names the
// gate and prices the selection, at a rate a 50-polls-a-minute worker can afford.
export type RegionalRehomePollGate =
| 'open'
| 'cohort-zero'
| 'process-safety-unavailable'
| 'control-closed'
| 'budget-closed'
| 'fleet-safety'
export const REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS = 60_000
const EMPTY_GATES: Record<RegionalRehomePollGate, number> = {
open: 0,
'cohort-zero': 0,
'process-safety-unavailable': 0,
'control-closed': 0,
'budget-closed': 0,
'fleet-safety': 0
}
export class RegionalRehomePollTelemetry {
private windowStartedAt: number | null = null
private gates = { ...EMPTY_GATES }
private candidates = 0
private selectionSamplesMs: number[] = []
constructor(private readonly write: (line: string) => void = (line) => console.warn(line)) {}
record(input: {
now: number
gate: RegionalRehomePollGate
candidates: number
selectionMs?: number
}): void {
if (this.windowStartedAt === null) this.windowStartedAt = input.now
this.gates[input.gate] += 1
this.candidates += input.candidates
if (input.selectionMs !== undefined) this.selectionSamplesMs.push(input.selectionMs)
if (input.now - this.windowStartedAt < REGIONAL_REHOME_POLL_SUMMARY_INTERVAL_MS) return
this.write(
JSON.stringify({
event: 'orca_relay_regional_rehome_poll_summary',
windowMs: input.now - this.windowStartedAt,
polls: Object.values(this.gates).reduce((total, count) => total + count, 0),
...this.gates,
candidates: this.candidates,
selectionMsMax: round(Math.max(0, ...this.selectionSamplesMs)),
selectionMsP95: round(percentile(this.selectionSamplesMs, 0.95))
})
)
this.windowStartedAt = input.now
this.gates = { ...EMPTY_GATES }
this.candidates = 0
this.selectionSamplesMs = []
}
}
function percentile(samples: number[], fraction: number): number {
if (!samples.length) return 0
const sorted = [...samples].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]!
}
function round(value: number): number {
return Math.round(value * 100) / 100
}
@@ -1760,7 +1760,7 @@ function hookAfterCandidateScan(
const decorate = (delegate: RelayDatabase): RelayDatabase => ({
query: async (sql, params) => {
const rows = await delegate.query(sql, params)
if (!fired && sql.includes('SELECT a.user_id, a.relay_host_id')) {
if (!fired && sql.includes('SELECT d.user_id, d.relay_host_id')) {
fired = true
await hook(delegate)
}
@@ -1,12 +1,20 @@
// A refused home cell is not fleet capacity, so it gets its own bucket rather
// than inflating the capacity count a run is read for.
const ASSIGNMENT_REJECTION_BUCKETS = {
relay_capacity_exhausted: 'assignment_capacity_exhausted',
relay_connection_headroom_exhausted: 'assignment_capacity_exhausted',
relay_home_cell_unavailable: 'assignment_home_cell_unavailable'
}
export function relayLoadFailureReason(error) {
const message = error instanceof Error ? error.message : String(error)
const tokenExchange = /^relay token exchange failed: ([1-5][0-9]{2})$/.exec(message)
if (tokenExchange) return `token_http_${tokenExchange[1]}`
const assignment =
/^relay assignment failed: ([1-5][0-9]{2})(?: (relay_capacity_exhausted|relay_connection_headroom_exhausted))?$/.exec(
message
)
if (assignment?.[1] === '503' && assignment[2]) return 'assignment_capacity_exhausted'
/^relay assignment failed: ([1-5][0-9]{2})(?: (relay_[a-z_]+))?$/.exec(message)
if (assignment?.[1] === '503' && assignment[2]) {
return ASSIGNMENT_REJECTION_BUCKETS[assignment[2]] ?? `assignment_http_${assignment[1]}`
}
if (assignment) return `assignment_http_${assignment[1]}`
const closed = /^control closed: ([0-9]{4})\b/.exec(message)
if (closed) return `control_close_${closed[1]}`
@@ -11,9 +11,10 @@ const { buildHostProofMacInput, HOST_CHALLENGE_PLAINTEXT_DOMAIN } = await import
requireFromRelay.resolve('@orca-cloud/relay-contract')
)
const CAPACITY_ASSIGNMENT_ERRORS = [
const REPORTABLE_ASSIGNMENT_ERRORS = [
'relay_capacity_exhausted',
'relay_connection_headroom_exhausted'
'relay_connection_headroom_exhausted',
'relay_home_cell_unavailable'
]
function waitForOpen(socket, timeoutMs = 10_000) {
@@ -744,7 +745,7 @@ export class RelayLoadControlPeer {
'relay assignment timeout',
(status, errorCode) =>
`relay assignment failed: ${status}${errorCode ? ` ${errorCode}` : ''}`,
CAPACITY_ASSIGNMENT_ERRORS
REPORTABLE_ASSIGNMENT_ERRORS
)
if (
typeof body.cellUrl !== 'string' ||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,322 @@
diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts
index 559b907416eb38318f439d060d7f89311ed34c7e..8541b4b0ea0d6b451aaae49007d69df64c5bd088 100644
--- a/src/IIPHandler.ts
+++ b/src/IIPHandler.ts
@@ -34,6 +34,7 @@ const DEFAULT_HEADER: IHeaderFields = {
export class IIPHandler implements IOscHandler, IResetHandler {
+ private _generation = 0;
private _aborted = false;
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
@@ -55,6 +56,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
}
public reset(): void {
+ this._generation++;
this._hp.reset();
this._dec.release();
this._qoiDec.release();
@@ -198,8 +200,13 @@ export class IIPHandler implements IOscHandler, IResetHandler {
blob = new Blob([this._dec.data8], { type: metrics.mime });
}
this._dec.release();
+ const generation = this._generation;
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
.then(bm => {
+ if (generation !== this._generation) {
+ bm.close();
+ return true;
+ }
this._storage.addImage(bm);
return true;
})
diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts
index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644
--- a/src/ImageAddon.ts
+++ b/src/ImageAddon.ts
@@ -113,6 +113,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
public dispose(): void {
+ for (const handler of this._handlers.values()) handler.reset();
for (const obj of this._disposables) {
obj.dispose();
}
diff --git a/src/ImageRenderer.ts b/src/ImageRenderer.ts
index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33a8aa814f 100644
--- a/src/ImageRenderer.ts
+++ b/src/ImageRenderer.ts
@@ -186,16 +186,17 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
- const cols = Math.ceil(img.width / width);
+ const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;
+ const cols = Math.ceil(img.width / sourceWidth);
- const sx = (tileId % cols) * width;
- const sy = Math.floor(tileId / cols) * height;
+ const sx = (tileId % cols) * sourceWidth;
+ const sy = Math.floor(tileId / cols) * sourceHeight;
const dx = col * width;
const dy = row * height;
// safari bug: never access image source out of bounds
- const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;
- const finalHeight = sy + height > img.height ? img.height - sy : height;
+ const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;
+ const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;
// Floor all pixel offsets to get stable tile mapping without any overflows.
// Note: For not pixel perfect aligned cells like in the DOM renderer
@@ -204,7 +205,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
- Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
+ Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)
);
}
@@ -219,19 +220,20 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
- const cols = Math.ceil(img.width / width);
- const sx = (tileId % cols) * width;
- const sy = Math.floor(tileId / cols) * height;
- const finalWidth = width + sx > img.width ? img.width - sx : width;
- const finalHeight = sy + height > img.height ? img.height - sy : height;
-
- const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);
+ const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;
+ const cols = Math.ceil(img.width / sourceWidth);
+ const sx = (tileId % cols) * sourceWidth;
+ const sy = Math.floor(tileId / cols) * sourceHeight;
+ const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;
+ const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;
+
+ const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
- 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
+ 0, 0, canvas.width, canvas.height
);
return canvas;
}
@@ -299,11 +301,16 @@ export class ImageRenderer extends Disposable implements IDisposable {
spec.actualCellSize.height = originalHeight;
return;
}
- const canvas = ImageRenderer.createCanvas(
- this.document,
- Math.ceil(spec.orig!.width * currentWidth / originalWidth),
- Math.ceil(spec.orig!.height * currentHeight / originalHeight)
- );
+ const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);
+ const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);
+ // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.
+ if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {
+ spec.actual = spec.orig;
+ spec.actualCellSize.width = originalWidth;
+ spec.actualCellSize.height = originalHeight;
+ return;
+ }
+ const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);
@@ -415,7 +422,11 @@ export class ImageRenderer extends Disposable implements IDisposable {
for (let i = 0; i < width; i += bWidth) {
ctx2.drawImage(blueprint, i, 0);
}
- ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);
+ const placeholder = this._placeholder;
+ ImageRenderer.createImageBitmap(placeholder).then(bitmap => {
+ if (this._placeholder !== placeholder) bitmap?.close();
+ else this._placeholderBitmap = bitmap;
+ }).catch(() => {});
}
public get document(): Document | undefined {
diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts
index de889dfff75d9ecc8ab47a025e6989ffe75bb202..54ebea9c061e5bb92b187cab7a53bc1fa320c4f8 100644
--- a/src/kitty/KittyGraphicsHandler.ts
+++ b/src/kitty/KittyGraphicsHandler.ts
@@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm';
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
+import { imageType } from '../IIPMetrics';
import { KittyImageStorage } from './KittyImageStorage';
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import {
@@ -37,6 +38,7 @@ const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
// Kitty graphics protocol handler with streaming base64 decoding.
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
private _aborted = false;
+ private _generation = 0;
private _decodeError = false;
private _activeDecoder: Base64Decoder | null = null;
@@ -80,6 +82,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
public reset(): void {
+ this._generation++;
this._cleanupAllPending();
if (this._activeDecoder) {
this._activeDecoder.release();
@@ -200,6 +203,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
this._activeDecoder = pending.decoder;
}
if (!this._activeDecoder) {
+ // Budget WASM capacity, including one page of decoder state and rounding.
+ const decoderCapacity = this._maxEncodedBytes + 131072;
+ if (decoderCapacity > this._opts.storageLimit * 1000000) {
+ this._aborted = true;
+ if (this._parsedCommand?.id !== undefined) {
+ this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);
+ }
+ return;
+ }
+ const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));
+ while (this._pendingTransmissions.size >= maxPending) {
+ const oldest = this._pendingTransmissions.entries().next().value;
+ if (!oldest) break;
+ oldest[1].decoder.release();
+ this._removePendingEntry(oldest[0]);
+ if (oldest[1].cmd.id !== undefined) {
+ this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);
+ }
+ }
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder.init();
}
@@ -550,9 +572,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
+ const generation = this._generation;
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
try {
+ if (generation !== this._generation) throw new Error('image decode canceled');
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
@@ -660,6 +684,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
}
+ if (generation !== this._generation) throw new Error('image decode canceled');
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
bitmap = undefined; // ownership transferred to storage
@@ -693,6 +718,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
if (image.format === KittyFormat.PNG) {
+ const metrics = imageType(bytes);
+ // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes
+ // back negative and a bare `>` pixel-limit test passes it; require positive.
+ if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {
+ throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');
+ }
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
@@ -775,27 +806,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
try {
return await this._decompress(compressed, 'deflate');
- } catch {
+ } catch (error) {
+ if (error instanceof RangeError) throw error;
return await this._decompress(compressed, 'deflate-raw');
}
}
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
- const ds = new DecompressionStream(format);
- const writer = ds.writable.getWriter();
- writer.write(compressed as BufferSource);
- writer.close();
-
+ const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);
+ let offsetIn = 0;
+ // Bound inflation within one native transform before its output is budgeted.
+ const source = new ReadableStream<BufferSource>({
+ pull(controller) {
+ if (offsetIn >= compressed.length) {
+ controller.close();
+ return;
+ }
+ const end = Math.min(offsetIn + 4096, compressed.length);
+ controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));
+ offsetIn = end;
+ }
+ });
+ const reader = source.pipeThrough(new DecompressionStream(format)).getReader();
const chunks: Uint8Array[] = [];
- const reader = ds.readable.getReader();
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- chunks.push(value);
+ let totalLength = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ totalLength += value.byteLength;
+ if (totalLength > limit) {
+ await reader.cancel().catch(() => {});
+ throw new RangeError('decompressed image exceeds byte limit');
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
}
- const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
diff --git a/src/kitty/KittyImageStorage.ts b/src/kitty/KittyImageStorage.ts
index 1f5c09ec9e2700f8f6dbd8436a1802217dfc99ef..016943a77c7e8e27da5899d54cd48d82761a87c8 100644
--- a/src/kitty/KittyImageStorage.ts
+++ b/src/kitty/KittyImageStorage.ts
@@ -83,6 +83,25 @@ export class KittyImageStorage implements IDisposable {
this._evictUndisplayedImages();
}
+ // Encoded images awaiting placement are outside ImageStorage's pixel budget.
+ // Unplaced payloads are evicted first so a new upload cannot erase a visible
+ // image while abandoned blobs still hold budget; placed ones go only when
+ // that is not enough, because the byte cap is a hard bound. The new image is
+ // always stored, so an oversized one overshoots by at most one payload
+ // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.
+ const byteLimit = this._storage.getLimit() * 1000000;
+ this._images.delete(imageId);
+ let retainedBytes = 0;
+ for (const image of this._images.values()) retainedBytes += image.data.size;
+ for (const evictPlaced of [false, true]) {
+ for (const [oldestId, image] of this._images) {
+ if (retainedBytes + imageData.data.size <= byteLimit) break;
+ if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;
+ retainedBytes -= image.data.size;
+ this.deleteById(oldestId);
+ }
+ }
+
this._images.set(imageId, {
...imageData,
id: imageId
+25
View File
@@ -111,6 +111,31 @@
"args": ["run", "esbuild-package"]
}
]
},
{
"name": "@xterm/addon-image",
"version": "0.10.0-beta.300",
"packageDir": "addons/addon-image",
"sourcePatch": "config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch",
"patch": "config/patches/@xterm__addon-image@0.10.0-beta.300.patch",
"generatedPaths": ["lib/"],
"build": [
{
"cwd": "../..",
"command": "npm",
"args": ["run", "build"]
},
{
"cwd": ".",
"command": "npm",
"args": ["run", "package"]
},
{
"cwd": "../..",
"command": "npm",
"args": ["run", "esbuild-package"]
}
]
}
],
"forbiddenBuildScripts": {
@@ -0,0 +1,453 @@
import { readFile } from 'node:fs/promises'
import { realpathSync } from 'node:fs'
import { basename, extname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as esbuild from 'esbuild'
import {
MOBILE_WEB_BUNDLE_ENTRYPOINT,
hashedAsset,
isDirectInvocation,
readDesktopVersion,
readProtocolWindow,
sha256Hex,
writeMobileWebBundleTree,
contentTypeForExtension
} from './build-mobile-web-bundle.mjs'
import {
ROUTE_SOURCE_LOADERS,
assertRoutesCarryNoSynchronousExports,
collectMobileWebAppRoutes,
renderMobileWebAppRouteManifest,
routePathnameFromKey
} from './mobile-web-app-route-manifest.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const mobileDir = join(projectDir, 'mobile')
const defaultAppDir = join(mobileDir, 'app')
const entryPoint = join(mobileDir, 'web-entry', 'index.tsx')
const defaultOutDir = join(projectDir, 'out', 'mobile-web-app')
/**
* Every shim the app bundle needs, each one a documented Metro/RN-Web gap. `appliesTo` reads the
* esbuild option that implements the shim, so the list cannot claim a shim the build does not
* apply and a dropped option fails the named shim rather than the whole build.
*/
export const MOBILE_WEB_APP_SHIMS = [
{
// react-native has no browser build; react-native-web is the whole point of Route A.
name: 'react-native-web-alias',
appliesTo: (options) => options.alias?.['react-native'] === 'react-native-web'
},
{
// RN ships untranspiled JSX inside .js files (expo-router's own build/ included).
name: 'js-as-jsx',
appliesTo: (options) => options.loader?.['.js'] === 'jsx'
},
{
// RN code assumes a Hermes/Metro `global`; the browser only has `globalThis`.
name: 'global-as-globalthis',
appliesTo: (options) => options.define?.global === 'globalThis'
},
{
// RN and Expo modules read process.env at module scope, before any of our code runs.
name: 'process-banner',
appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true
},
{
// lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does
// not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not.
// Web-build only: patching the package would change what the shipped native app consumes.
name: 'lucide-barrel-provider',
appliesTo: (options) =>
options.plugins?.some((plugin) => plugin.name === LUCIDE_PLUGIN_NAME) === true
},
{
// AsyncStorage's web build is window.localStorage, which the shell's page does not have:
// Android turns DOM storage off and on iOS the origin host is the session id, so anything
// written there is gone on the next remount. The page module holds the app's own values,
// primed by `init` and written back over the `storage` grant.
name: 'async-storage-over-the-bridge',
appliesTo: (options) =>
options.alias?.['@react-native-async-storage/async-storage'] === PAGE_ASYNC_STORAGE_MODULE
},
{
// esbuild has no require.context, so the route tree is generated and injected.
name: 'route-manifest',
appliesTo: (options) =>
options.plugins?.some((plugin) => plugin.name === ROUTE_MANIFEST_PLUGIN_NAME) === true
}
]
const PAGE_ASYNC_STORAGE_MODULE = join(
mobileDir,
'src',
'mobile-web-shell',
'bridge',
'page-async-storage.ts'
)
const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest'
const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider'
/** The entry output's name, so classifying the outputs never has to guess which one it is. */
const ENTRY_CHUNK_NAME = 'entry'
// mobile/web-entry/route-manifest.ts is a real typed file rather than a virtual specifier, so the
// entry typechecks and Metro can still resolve it; only its body is replaced here.
function routeManifestPlugin(manifestSource) {
return {
name: ROUTE_MANIFEST_PLUGIN_NAME,
setup(build) {
build.onLoad({ filter: /web-entry[\\/]route-manifest\.ts$/ }, () => ({
contents: manifestSource,
loader: 'js',
resolveDir: mobileDir
}))
}
}
}
const lucideBarrelPlugin = {
name: LUCIDE_PLUGIN_NAME,
setup(build) {
build.onLoad({ filter: /lucide-react-native[\\/].*[\\/]context\.mjs$/ }, async (args) => ({
contents: `${await readFile(args.path, 'utf8')}\nexport const LucideProvider = ({ children }) => children;\n`,
loader: 'js'
}))
}
}
/** Split out so a test can read the options MOBILE_WEB_APP_SHIMS claims, without a build. */
export function mobileWebAppBuildOptions(routes) {
return {
// Fixed so no absolute path of this checkout can reach the output.
absWorkingDir: mobileDir,
entryPoints: [entryPoint],
bundle: true,
minify: true,
// Virtual: write is false, so outdir only names the emitted files esbuild hands back.
outdir: 'dist',
write: false,
// esm, because `splitting` requires it and a per-route chunk is the point: with iife and
// static imports esbuild emitted one 8.16 MB script for all 14 routes.
format: 'esm',
splitting: true,
// esbuild's `[hash]` is over the metafile's input keys, which are paths relative to
// absWorkingDir, so this name is not a function of the bytes and differs between two
// checkouts of one commit. It is a placeholder: renameOutputsByContent replaces it below.
chunkNames: '[hash]',
// Pinned rather than defaulted, so the entry is found by name and not by elimination.
entryNames: ENTRY_CHUNK_NAME,
target: ['es2022'],
charset: 'utf8',
legalComments: 'none',
// No sourcemap: it is an emitted file and would carry this checkout's absolute paths into the
// bundle. The metafile carries them too but is never written and never hashed; it is the only
// thing that says which output is the entry, which of its imports are static, and which
// outputs each one names.
sourcemap: false,
metafile: true,
logLevel: 'silent',
jsx: 'automatic',
// One React: resolve everything from mobile/node_modules, which is where the entry lives.
nodePaths: [join(mobileDir, 'node_modules')],
alias: {
'react-native': 'react-native-web',
'@react-native-async-storage/async-storage': PAGE_ASYNC_STORAGE_MODULE
},
plugins: [routeManifestPlugin(renderMobileWebAppRouteManifest(routes)), lucideBarrelPlugin],
resolveExtensions: [
'.web.tsx',
'.web.ts',
'.web.jsx',
'.web.js',
'.tsx',
'.ts',
'.jsx',
'.js',
'.json'
],
// Images are emitted as same-origin assets, not data: URLs: the shell's CSP sets
// img-src 'self', which refuses data:. Content-hashed names keep the buildId reproducible.
// A font would fail the build here rather than silently ship under font-src 'none'.
loader: {
...ROUTE_SOURCE_LOADERS,
'.png': 'file',
'.jpg': 'file',
'.jpeg': 'file',
'.gif': 'file',
'.webp': 'file',
'.svg': 'file'
},
assetNames: '[hash]',
// Absolute, because the document is served at every route depth and a path relative to the
// script would resolve against the route instead.
publicPath: '/assets',
banner: {
js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };"
},
define: {
global: 'globalThis',
__DEV__: 'false',
'process.env.NODE_ENV': '"production"',
'process.env.EXPO_OS': '"web"',
'process.env.EXPO_ROUTER_IMPORT_MODE': '"sync"'
}
}
}
/**
* What the browser must have before the first route can paint: the entry plus every chunk it
* reaches by static import, transitively. A dynamic import is what the split exists to defer, so
* it is where this stops.
*
* The bound the verifier holds is this number and not the entry file alone, because esbuild puts
* the code shared by entry and routes in a chunk the entry imports statically: budgeting the entry
* file on its own would fall as the shared chunk grew.
*/
export function entryStaticClosure(metafile, entryOutputPath) {
const reached = new Set([entryOutputPath])
const queue = [entryOutputPath]
while (queue.length > 0) {
const current = queue.shift()
for (const imported of metafile.outputs[current]?.imports ?? []) {
if (imported.kind !== 'import-statement' || reached.has(imported.path)) {
continue
}
reached.add(imported.path)
queue.push(imported.path)
}
}
return reached
}
/**
* Every emitted output, renamed to the sha256 of its own final bytes.
*
* esbuild's `[hash]` is computed over the metafile's input keys, and those keys are paths
* relative to absWorkingDir. A tree whose mobile/node_modules is a symlink keys most of its
* inputs as `../../<somewhere>/...`, a tree that holds a real directory keys them as
* `node_modules/...`, and a byte-identical chunk comes out under a different name in each. The
* name is embedded in every importer, so the difference cascades into a different buildId for one
* commit -- and every phone re-downloads a bundle whose bytes never changed.
*
* Renaming here is what removes the path from the output. Leaves first, so an importer is hashed
* only once the names written inside it are final: an image before the chunk that loads it, a
* chunk before the chunk that imports it, the entry last. The result is what `hashedAsset` would
* name each of these anyway, which is how the name inside the bytes and the manifest's own sha256
* stay the same string.
*/
export function renameOutputsByContent(metafile, outputFiles) {
const emitted = new Map(
outputFiles.map((file) => [basename(file.path), Buffer.from(file.contents)])
)
const importsOf = new Map(
Object.entries(metafile.outputs).map(([output, { imports }]) => [
basename(output),
(imports ?? []).map((entry) => basename(entry.path)).filter((name) => emitted.has(name))
])
)
const renamed = new Map()
const open = new Set()
function rename(name) {
const done = renamed.get(name)
if (done) {
return done
}
if (open.has(name)) {
// Two outputs naming each other have no content hash at all, so this is a hard stop rather
// than a fallback. esbuild's splitting emits a DAG; nothing in the tree has produced one.
throw new Error(
`[build-mobile-web-app-bundle] ${name} is in an output cycle and cannot be content-named`
)
}
open.add(name)
let bytes = emitted.get(name)
for (const child of importsOf.get(name) ?? []) {
const { name: childName } = rename(child)
// publicPath already rewrote the specifier to this exact shape, and an esbuild output name
// is a token that appears nowhere else.
bytes = Buffer.from(
bytes.toString('utf8').split(`/assets/${child}`).join(`/assets/${childName}`),
'utf8'
)
}
open.delete(name)
const result = { name: `${sha256Hex(bytes)}${extname(name)}`, bytes }
renamed.set(name, result)
return result
}
for (const name of [...emitted.keys()].sort()) {
rename(name)
}
return renamed
}
/**
* Which emitted chunk each route key's `import()` lands in. esbuild puts a route module in exactly
* one output, so the metafile's own inputs answer it; nothing downstream can, because by then
* every name is a hash of bytes and the route's source path is gone from the bundle.
*/
export function routeChunkNames(metafile, routes, renamed) {
const owner = new Map()
for (const [output, { inputs }] of Object.entries(metafile.outputs)) {
for (const input of Object.keys(inputs ?? {})) {
// Absolute, and through realpath on the lookup side below: esbuild writes its input keys
// relative to absWorkingDir after resolving symlinks, so a route reached through one (every
// scratch tree under /var on macOS) is keyed by a path the caller never spelled.
owner.set(resolve(mobileDir, input), basename(output))
}
}
return Object.fromEntries(
routes.map(({ key, module }) => {
const emittedName = owner.get(realpathSync(module))
if (!emittedName) {
throw new Error(`[build-mobile-web-app-bundle] ${key} reached no output`)
}
return [key, renamed.get(emittedName).name]
})
)
}
const isScriptOutput = (path) => path.endsWith('.js')
// appDir is a seam for the tests, which bundle a scratch route tree; production always uses mobile/app.
export async function bundleMobileWebApp({ appDir = defaultAppDir } = {}) {
const routes = await collectMobileWebAppRoutes(appDir)
await assertRoutesCarryNoSynchronousExports(routes)
const result = await esbuild.build(mobileWebAppBuildOptions(routes))
const entryOutputPath = Object.keys(result.metafile.outputs).find(
(path) => basename(path) === `${ENTRY_CHUNK_NAME}.js`
)
if (!entryOutputPath) {
throw new Error('[build-mobile-web-app-bundle] esbuild emitted no entry script')
}
const renamed = renameOutputsByContent(result.metafile, result.outputFiles)
const entry = renamed.get(basename(entryOutputPath))
const byName = (left, right) => (left.name < right.name ? -1 : 1)
const others = [...renamed.entries()]
.filter(([emittedName]) => emittedName !== basename(entryOutputPath))
.map(([emittedName, output]) => ({ emittedName, ...output }))
// Chunks keep their new name into the served path: the entry imports them by it, and
// publicPath has already made that specifier /assets/<name>.
const chunks = others.filter(({ emittedName }) => isScriptOutput(emittedName)).sort(byName)
const images = others.filter(({ emittedName }) => !isScriptOutput(emittedName)).sort(byName)
const closure = entryStaticClosure(result.metafile, entryOutputPath)
return {
script: entry.bytes,
chunks,
images,
// Counted off the renamed bytes rather than the metafile's own sizes, which are from before
// the names inside each output grew. Only the metafile knows which import is static; see
// entryStaticClosure.
entryStaticBytes: [...closure].reduce(
(total, path) => total + (renamed.get(basename(path))?.bytes.byteLength ?? 0),
0
),
routeKeys: routes.map((route) => route.key),
routeChunks: routeChunkNames(result.metafile, routes, renamed)
}
}
/**
* The declared page routes, checked against the tree that was actually bundled.
*
* A declaration naming a screen this bundle has no module for would reach a phone as a route the
* shell opens the page for and the page then paints as Unmatched. Failing the build is the only
* place that mismatch is visible to whoever wrote the declaration.
*/
export function resolveMobileWebPageRoutes(routeKeys, declared = MOBILE_WEB_PAGE_ROUTES) {
const bundled = new Set(routeKeys.map(routePathnameFromKey).filter((path) => path !== null))
for (const route of declared) {
if (!bundled.has(route.pathname)) {
throw new Error(
`[build-mobile-web-app-bundle] declared page route ${route.pathname} has no module in the bundle`
)
}
}
return declared.map((route) => ({ pathname: route.pathname, grants: [...route.grants] }))
}
/**
* `pageRoutes` rides with `appDir`: the declarations name screens in the real route tree, so a
* caller bundling some other tree has none to check against and says so by passing its own.
*/
export async function buildMobileWebAppBundle({
appDir,
outDir = defaultOutDir,
pageRoutes = MOBILE_WEB_PAGE_ROUTES
} = {}) {
const [
desktopVersion,
protocolWindow,
{ script, chunks, images, entryStaticBytes, routeChunks, routeKeys }
] = await Promise.all([
readDesktopVersion(),
readProtocolWindow(),
bundleMobileWebApp({ appDir })
])
// Every output is already named by its own bytes, and a name is written inside whatever imports
// it, so hashedAsset here reproduces the name rather than choosing one.
const scriptAsset = hashedAsset(script, 'js')
const written = [
scriptAsset,
...[...chunks, ...images].map(({ name, bytes }) => hashedAsset(bytes, extname(name).slice(1)))
]
// Root-absolute, unlike the Phase A bootstrap's bare relative src: this document is served at
// every route depth (/h/<hostId>/tasks), where a relative href resolves against the route and
// 404s. A <base> tag would be the other fix, but the shell's CSP sets base-uri 'none'.
// type="module", because the entry is esm and reaches its routes through import(). Same-origin
// module and chunk both load under the shell's script-src 'self'; the policy is unchanged.
const html =
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8" />\n' +
'<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />\n' +
'<title>Orca</title>\n</head>\n<body>\n<div id="root"></div>\n' +
`<script type="module" src="/${scriptAsset.path}"></script>\n</body>\n</html>\n`
const indexBytes = Buffer.from(html, 'utf8')
const indexAsset = {
bytes: indexBytes,
path: MOBILE_WEB_BUNDLE_ENTRYPOINT,
sha256: sha256Hex(indexBytes),
byteLength: indexBytes.byteLength,
contentType: contentTypeForExtension('html')
}
const { manifest } = await writeMobileWebBundleTree({
outDir,
written: [indexAsset, ...written],
desktopVersion,
protocolWindow,
routes: resolveMobileWebPageRoutes(routeKeys, pageRoutes)
})
return {
manifest,
outDir,
routeChunks,
routeKeys,
entryStaticBytes,
// The entry counts: it is a chunk the browser fetches, and the budget is about how many.
chunkCount: chunks.length + 1,
// Everything the routes import that is not a script, which is the rest of the asset budget.
imageCount: images.length
}
}
if (isDirectInvocation(import.meta.url, process.argv[1])) {
try {
const { manifest, outDir, routeKeys, entryStaticBytes, chunkCount } =
await buildMobileWebAppBundle()
console.log(
`[build-mobile-web-app-bundle] OK — ${String(routeKeys.length)} route(s), ` +
`${String(chunkCount)} chunk(s), ${String(entryStaticBytes)} bytes before the first route, ` +
`${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes, ` +
`buildId ${manifest.buildId} -> ${outDir}`
)
} catch (error) {
// The route guards fail here by design, and every throw on this path already names its
// source, so a stack only buries which route and which export.
console.error(error.message)
process.exit(1)
}
}
@@ -0,0 +1,665 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_APP_SHIMS,
bundleMobileWebApp,
buildMobileWebAppBundle,
entryStaticClosure,
mobileWebAppBuildOptions,
renameOutputsByContent,
resolveMobileWebPageRoutes,
routeChunkNames
} from './build-mobile-web-app-bundle.mjs'
import {
MOBILE_WEB_APP_ROUTE_ROOT,
ROUTE_SOURCE_LOADERS,
collectMobileWebAppRouteKeys,
collectMobileWebAppRoutes,
routePathnameFromKey
} from './mobile-web-app-route-manifest.mjs'
import {
MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES,
MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES,
MOBILE_WEB_APP_SOURCE_DIRS,
assertAssetCeilingFitsShell,
mobileWebAppBundleMaxAssets,
mobileWebAppBundleMaxChunks,
readMobileWebBundleMaxAssets,
verifyMobileWebAppBundle
} from './verify-mobile-web-app-bundle.mjs'
import {
BINARY_SOURCE_EXTENSIONS,
assertNoCarriageReturnsInSource
} from './verify-mobile-web-bundle.mjs'
import {
computeMobileWebBundleBuildId,
hashedAsset,
readDesktopVersion,
readProtocolWindow,
sha256Hex,
writeMobileWebBundleTree
} from './build-mobile-web-bundle.mjs'
import {
MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES,
MOBILE_WEB_BUNDLE_MAX_ASSETS
} from '../../src/shared/mobile-web-bundle/manifest-contract.js'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const appDir = join(projectDir, 'mobile', 'app')
// The sharded `test` job does not install mobile dependencies, so anything that runs esbuild over
// the route tree is skipped there and run for real in pr.yml's mobile_web_app job.
const bundles = mobileWebAppDependenciesPresent()
const describeBundling = bundles ? describe : describe.skip
const itBundling = bundles ? it : it.skip
/** Every script the page loads. A route's code is in a chunk now, not in the entry. */
function allScriptSource({ script, chunks }) {
return [script, ...chunks.map((chunk) => chunk.bytes)].map((bytes) => bytes.toString('utf8'))
}
async function withScratch(run) {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-test-'))
try {
return await run(scratch)
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
describe('the page routes the manifest declares', () => {
it('turns a route key into the URL pattern expo-router gives it', () => {
expect(routePathnameFromKey('./h/[hostId]/index.tsx')).toBe('/h/[hostId]')
expect(routePathnameFromKey('./h/[hostId]/tasks.tsx')).toBe('/h/[hostId]/tasks')
expect(routePathnameFromKey('./h/[hostId]/session/[worktreeId].tsx')).toBe(
'/h/[hostId]/session/[worktreeId]'
)
})
it('answers null for a layout, which is not a screen anyone navigates to', () => {
expect(routePathnameFromKey('./h/_layout.tsx')).toBeNull()
expect(routePathnameFromKey('./h/[hostId]/_layout.tsx')).toBeNull()
})
it('declares only routes the bundle has a module for', async () => {
const keys = await collectMobileWebAppRouteKeys(appDir)
expect(resolveMobileWebPageRoutes(keys)).toEqual([
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] }
])
})
it('fails the build on a declaration the bundle cannot render', () => {
// The mismatch reaches a phone as a route the shell opens the page for and the page then
// paints as Unmatched. This is the only place whoever wrote the declaration can see it.
expect(() =>
resolveMobileWebPageRoutes(
['./h/[hostId]/index.tsx'],
[{ pathname: '/h/[hostId]/gone', grants: [] }]
)
).toThrow('has no module in the bundle')
})
itBundling(
'reaches the built manifest, where the build id does not move for it',
async () => {
await withScratch(async (scratch) => {
const { manifest } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
expect(manifest.routes).toEqual([
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] }
])
// The routes are derived from the same tree the script is built from, so the assets
// already decide them and the id has no reason to carry them as well.
expect(manifest.buildId).toBe(computeMobileWebBundleBuildId(manifest.assets))
})
},
240_000
)
})
describe('the CRLF pin', () => {
it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => {
const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8')
for (const tree of MOBILE_WEB_APP_SOURCE_DIRS) {
const pattern = `/${relative(projectDir, tree).split('\\').join('/')}/**`
for (const extension of BINARY_SOURCE_EXTENSIONS) {
// Without the exemption the blanket `text eol=lf` pin above it rewrites the binary and
// every asset hash with it.
expect(attributes, `${pattern}/*${extension} is not exempt`).toContain(
`${pattern}/*${extension} -text`
)
}
}
})
})
describeBundling('the app bundle', () => {
it('resolves react-native to react-native-web and leaves no require.context', async () => {
const sources = allScriptSource(await bundleMobileWebApp())
for (const source of sources) {
expect(source).not.toContain('require.context')
}
// react-native-web's touch responder is proof the alias resolved rather than the native stub.
expect(sources.some((source) => source.includes('ResponderTouchHistoryStore'))).toBe(true)
}, 120_000)
it('cuts the routes into chunks the entry does not load', async () => {
const { script, chunks, entryStaticBytes } = await bundleMobileWebApp()
expect(chunks.length).toBeGreaterThan(1)
// The entry's own bytes plus the chunks it imports statically, which is what the browser
// parses before any route paints. Every route chunk is outside it.
expect(entryStaticBytes).toBeGreaterThan(script.byteLength)
const allBytes =
script.byteLength + chunks.reduce((total, chunk) => total + chunk.bytes.byteLength, 0)
expect(entryStaticBytes).toBeLessThan(allBytes)
}, 120_000)
it('names the chunk each route lands in', async () => {
const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp()
expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort())
const emitted = new Set(chunks.map((chunk) => chunk.name))
for (const [key, name] of Object.entries(routeChunks)) {
expect(emitted, key).toContain(name)
}
// One chunk per route, never the entry: that is what a client-side navigation fetches.
expect(new Set(Object.values(routeChunks)).size).toBe(routeKeys.length)
}, 120_000)
it('counts only static imports into what loads before the first route', () => {
const metafile = {
outputs: {
'dist/entry.js': {
bytes: 10,
imports: [
{ path: 'dist/shared.js', kind: 'import-statement' },
{ path: 'dist/route.js', kind: 'dynamic-import' }
]
},
'dist/shared.js': {
bytes: 20,
imports: [{ path: 'dist/deep.js', kind: 'import-statement' }]
},
'dist/deep.js': { bytes: 30, imports: [] },
'dist/route.js': { bytes: 40, imports: [] }
}
}
expect([...entryStaticClosure(metafile, 'dist/entry.js')]).toEqual([
'dist/entry.js',
'dist/shared.js',
'dist/deep.js'
])
})
it('does not walk a chunk cycle forever', () => {
const metafile = {
outputs: {
'dist/entry.js': { bytes: 1, imports: [{ path: 'dist/a.js', kind: 'import-statement' }] },
'dist/a.js': { bytes: 1, imports: [{ path: 'dist/entry.js', kind: 'import-statement' }] }
}
}
expect(entryStaticClosure(metafile, 'dist/entry.js').size).toBe(2)
})
itBundling(
'refuses to build a route the lazy manifest would strip an export from',
async () => {
await withScratch(async (scratch) => {
const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(directory, { recursive: true })
await writeFile(
join(directory, 'index.tsx'),
'export default function Route() { return null }\n'
)
await expect(bundleMobileWebApp({ appDir: scratch })).resolves.toBeTruthy()
await writeFile(
join(directory, 'settings.tsx'),
'const anchor = { anchor: "index" }\nexport { anchor as unstable_settings }\nexport default function Route() { return null }\n'
)
// The build is where this has to fail: the page it would otherwise emit mounts with the
// export silently gone, which is a blank screen on a phone and nothing in any log.
await expect(bundleMobileWebApp({ appDir: scratch })).rejects.toThrow(
/settings\.tsx.*unstable_settings/s
)
})
},
240_000
)
itBundling(
'refuses a route whose star re-export it cannot read',
async () => {
await withScratch(async (scratch) => {
const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'boundary.ts'), 'export const value = 1\n')
await writeFile(
join(directory, 'index.tsx'),
'export * from "./boundary"\nexport default function Route() { return null }\n'
)
await expect(bundleMobileWebApp({ appDir: scratch })).rejects.toThrow(
/index\.tsx.*boundary/s
)
})
},
240_000
)
it('bundles every route module', async () => {
const { routeKeys } = await bundleMobileWebApp()
expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir))
}, 120_000)
it("bundles a route's .web.tsx sibling instead of the native file, changing the bytes", async () => {
await withScratch(async (scratch) => {
const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(directory, { recursive: true })
const route = (marker) => `export default function Route() { return '${marker}' }\n`
await writeFile(join(directory, 'index.tsx'), route('native-route-marker'))
const before = await bundleMobileWebApp({ appDir: scratch })
const has = (bundle, marker) =>
allScriptSource(bundle).some((source) => source.includes(marker))
expect(has(before, 'native-route-marker')).toBe(true)
await writeFile(join(directory, 'index.web.tsx'), route('web-route-marker'))
const after = await bundleMobileWebApp({ appDir: scratch })
expect(has(after, 'web-route-marker')).toBe(true)
expect(has(after, 'native-route-marker')).toBe(false)
// Different script bytes means a different asset sha and so a different buildId.
expect(after.script.equals(before.script)).toBe(false)
})
}, 240_000)
/**
* The same route tree, bundled from two directories at different depths. esbuild's own `[hash]`
* is computed over the metafile's input keys, which are paths relative to absWorkingDir, so two
* checkouts of one commit -- at different depths, or one with mobile/node_modules as a symlink
* and one with it as a directory -- name a byte-identical chunk differently. The rename
* cascades through every importer into a different buildId, and every phone re-downloads a
* bundle whose bytes did not change.
*/
async function bundleFromDepth(root, depth) {
const nested = join(root, ...Array.from({ length: depth }, (_, index) => `d${String(index)}`))
const directory = join(nested, MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(directory, { recursive: true })
// Two routes over one import, which is what makes esbuild emit a shared chunk to name.
await writeFile(join(directory, 'shared.ts'), 'export const marker = "shared-marker"\n')
for (const name of ['index.tsx', 'other.tsx']) {
await writeFile(
join(directory, name),
`import { marker } from "./shared"\nexport default function Route() { return marker + "${name}" }\n`
)
}
return { appDir: nested, bundle: await bundleMobileWebApp({ appDir: nested }) }
}
it('names every output by its bytes, so another checkout path builds the same bundle', async () => {
await withScratch(async (shallow) => {
await withScratch(async (deep) => {
const near = await bundleFromDepth(shallow, 1)
const far = await bundleFromDepth(deep, 5)
const names = ({ bundle }) => [...bundle.chunks, ...bundle.images].map((one) => one.name)
expect(names(far)).toEqual(names(near))
expect(far.bundle.script.equals(near.bundle.script)).toBe(true)
// The whole point: the manifest the phone compares is the same document.
const buildIdFrom = async ({ appDir }) =>
withScratch(async (out) => {
const { manifest } = await buildMobileWebAppBundle({
appDir,
outDir: join(out, 'x'),
// A synthetic tree: the real declarations name screens it does not have.
pageRoutes: []
})
return manifest.buildId
})
expect(await buildIdFrom(far)).toBe(await buildIdFrom(near))
})
})
}, 240_000)
it("names an output the same way the manifest's own asset hash does", async () => {
const { script, chunks } = await bundleMobileWebApp()
// The name is embedded in the importer, so it cannot be recomputed later; this is what says
// the name inside the bytes and the manifest's sha256 of those bytes are the same string.
expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`)
for (const chunk of chunks) {
expect(chunk.name).toBe(`${sha256Hex(chunk.bytes)}.js`)
}
}, 120_000)
it('asks esbuild for the split the budgets assume', async () => {
const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir))
// Each of these is load-bearing for a budget below: esm and splitting are what make a route a
// chunk, and the metafile is the only thing that says which imports are static.
expect(options.format).toBe('esm')
expect(options.splitting).toBe(true)
expect(options.chunkNames).toBe('[hash]')
expect(options.metafile).toBe(true)
})
it('reads a route source the same way the export guard does', async () => {
const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir))
// The guard parses each route on its own, outside this build. Sharing the table is what stops
// a loader the bundle relies on from being missing there and reported as a syntax error.
for (const [extension, loader] of Object.entries(ROUTE_SOURCE_LOADERS)) {
expect(options.loader[extension], extension).toBe(loader)
}
})
it('applies every shim it names', async () => {
const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir))
for (const shim of MOBILE_WEB_APP_SHIMS) {
expect(shim.appliesTo(options), `${shim.name} is named but not applied`).toBe(true)
}
})
it('fails the named shim, not the whole build, when its option goes missing', async () => {
const options = mobileWebAppBuildOptions(await collectMobileWebAppRoutes(appDir))
// Each shim reads a different option, so removing one leaves the other five true. Without
// that, the list could name a shim the build stopped applying.
const stripped = {
...options,
alias: {},
loader: {},
define: {},
banner: {},
plugins: []
}
expect(MOBILE_WEB_APP_SHIMS.filter((shim) => shim.appliesTo(stripped))).toEqual([])
})
it('keeps the shims out of the shipped Phase A bootstrap builder', async () => {
const shipped = await readFile(
join(projectDir, 'config', 'scripts', 'build-mobile-web-bundle.mjs'),
'utf8'
)
for (const { name } of MOBILE_WEB_APP_SHIMS) {
expect(shipped, `the Phase A bootstrap builder mentions ${name}`).not.toContain(name)
}
expect(shipped).not.toContain('react-native-web')
expect(shipped).not.toContain('lucide')
})
it('embeds no absolute path from this checkout', async () => {
// Every chunk, not only the entry: the route manifest names each route by absolute path, and
// the chunk that import resolves to is where such a path would survive.
for (const source of allScriptSource(await bundleMobileWebApp())) {
expect(source).not.toContain(projectDir)
}
}, 120_000)
it('builds the same buildId twice', async () => {
const first = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'a') })
)
const second = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'b') })
)
expect(first.manifest.buildId).toBe(second.manifest.buildId)
}, 120_000)
it('loads the entry as a module, so its route imports resolve', async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'module-tag')
const { manifest } = await buildMobileWebAppBundle({ outDir })
const html = await readFile(join(outDir, 'index.html'), 'utf8')
// import() in a classic script is a syntax error, so the tag and the format are one fact.
expect(html).toContain('<script type="module" src="/assets/')
const entry = html.match(/src="\/(assets\/[^"]+)"/)?.[1]
expect(manifest.assets.map((asset) => asset.path)).toContain(entry)
})
}, 120_000)
it('writes the manifest shape the packaging contract reads', async () => {
const { manifest } = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'c') })
)
expect(manifest.schemaVersion).toBe(1)
expect(manifest.entrypoint).toBe('index.html')
expect(manifest.assets.map((asset) => asset.path)).toContain('index.html')
expect(manifest.totalBytes).toBe(
manifest.assets.reduce((total, asset) => total + asset.byteLength, 0)
)
}, 120_000)
})
describe('the Phase C budget', () => {
it('sits below the contract per-asset ceiling, so growth trips a build not a phone', () => {
expect(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES).toBeLessThan(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES)
})
itBundling(
'is not already exceeded by the current bundle',
async () => {
const { manifest, chunkCount, entryStaticBytes, imageCount, routeKeys } = await withScratch(
(scratch) => buildMobileWebAppBundle({ outDir: join(scratch, 'd') })
)
expect(manifest.totalBytes).toBeLessThanOrEqual(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)
expect(manifest.assets.length).toBeLessThanOrEqual(
mobileWebAppBundleMaxAssets(routeKeys.length, imageCount)
)
expect(chunkCount).toBeLessThanOrEqual(mobileWebAppBundleMaxChunks(routeKeys.length))
expect(entryStaticBytes).toBeLessThanOrEqual(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES)
},
120_000
)
it('says which node may be statically imported, and does not promise a route may', async () => {
const source = await readFile(
join(projectDir, 'config', 'scripts', 'verify-mobile-web-app-bundle.mjs'),
'utf8'
)
// The bound reads like a per-route escape hatch and is not one: 5 of the 14 routes break it
// on their own. What keeps it survivable is that expo-router wants a synchronous export off
// layout nodes only, so the note has to name the layout and the export that drives it.
const doc = source.slice(
0,
source.indexOf('export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES')
)
const note = doc.slice(doc.lastIndexOf('/**'))
expect(note).toContain('h/_layout.tsx')
expect(note).toContain('unstable_settings')
})
it('budgets what loads first well under what the whole page weighs', () => {
// The point of the split: the entry budget is the one a route must not grow, and it is a
// fraction of the total the bundle is still allowed to weigh.
expect(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES).toBeLessThan(
MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES
)
})
it('derives the chunk ceiling from the route count, not from a measured number', async () => {
// A chunk is emitted per distinct set of importers, so the count is combinatorial rather than
// one per route. Measured while building this: 8 routes emit 23 chunks, 10 emit 40, 12 emit
// 47, 14 emit 53 -- about 3 more per route at the top. The ceiling allows 4 and starts 16
// above zero, so the next few routes land under it instead of failing on a pinned number.
for (const [routes, measured] of [
[8, 23],
[10, 40],
[12, 47],
[14, 53]
]) {
expect(mobileWebAppBundleMaxChunks(routes), `${String(routes)} routes`).toBeGreaterThan(
measured
)
}
expect(mobileWebAppBundleMaxChunks(14)).toBe(72)
expect(mobileWebAppBundleMaxChunks(15) - mobileWebAppBundleMaxChunks(14)).toBe(4)
})
it('derives the asset ceiling so the chunk ceiling is always the one that trips first', () => {
// A bundle's assets are its chunks, its images and the document. Asserting one constant under
// another did not say that: with 42 images, 4 * 18 + 16 chunks plus 42 plus the document is
// 131 assets, over the flat 128 the ceiling used to be, so from 18 routes on the asset count
// failed first and named the wrong thing.
for (const routeCount of [14, 18, 24, 40]) {
for (const imageCount of [0, 42, 120]) {
const chunks = mobileWebAppBundleMaxChunks(routeCount)
expect(mobileWebAppBundleMaxAssets(routeCount, imageCount)).toBe(chunks + imageCount + 1)
// The ordering claim itself: a bundle at the chunk ceiling is exactly at the asset
// ceiling, so no bundle can pass the chunk check and fail the asset one.
expect(chunks + imageCount + 1).toBeLessThanOrEqual(
mobileWebAppBundleMaxAssets(routeCount, imageCount)
)
}
}
})
itBundling(
'keeps the derived ceiling under the map the phone actually holds',
async () => {
const { manifest, routeKeys, imageCount } = await withScratch((scratch) =>
buildMobileWebAppBundle({ outDir: join(scratch, 'e') })
)
const ceiling = mobileWebAppBundleMaxAssets(routeKeys.length, imageCount)
expect(manifest.assets.length).toBeLessThanOrEqual(ceiling)
// The native side refuses a manifest past this, so the derived ceiling has to stay inside it.
expect(ceiling).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_MAX_ASSETS)
// And the build is what has to say so: the guard runs on the counts this bundle measured.
const shellCeiling = await readMobileWebBundleMaxAssets()
expect(assertAssetCeilingFitsShell(routeKeys.length, imageCount, shellCeiling)).toBe(ceiling)
},
120_000
)
it('fails the build when the derived ceiling passes what the phone will accept', async () => {
// The shell hands back null for a manifest over its own ceiling, so a derived ceiling above
// that ships a green build no device can open. At the 42 images the tree carries, 4r + 16 +
// 42 + 1 crosses 256 at 50 routes, which Phase C reaches.
expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS)
expect(assertAssetCeilingFitsShell(49, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(255)
expect(() => assertAssetCeilingFitsShell(50, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
/259 .*256/
)
})
})
describe('the verifier', () => {
itBundling(
'accepts a bundle it has just built',
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web-app')
await buildMobileWebAppBundle({ outDir })
await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).resolves.toBeDefined()
})
},
240_000
)
itBundling(
"rejects a buildId the manifest's own asset list does not derive",
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web-app')
await buildMobileWebAppBundle({ outDir })
const manifestPath = join(outDir, 'manifest.json')
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
manifest.buildId = 'f'.repeat(64)
await writeFile(manifestPath, JSON.stringify(manifest), 'utf8')
await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).rejects.toThrow(
'does not match its asset list'
)
})
},
240_000
)
itBundling(
'rejects a self-consistent bundle a fresh build does not reproduce',
async () => {
await withScratch(async (scratch) => {
const outDir = join(scratch, 'mobile-web-app')
const { manifest } = await buildMobileWebAppBundle({ outDir })
// What a stale out/ actually looks like: every digest agrees with its bytes and the
// buildId derives from the asset list, but the source has moved on. Only the two fresh
// builds the verifier runs can tell, which is the check this covers.
const assets = await Promise.all(
manifest.assets.map(async (asset) => ({
...asset,
bytes: await readFile(join(outDir, asset.path))
}))
)
const document = assets.find((asset) => asset.path === manifest.entrypoint)
document.bytes = Buffer.concat([document.bytes, Buffer.from('<!-- drift -->\n', 'utf8')])
document.sha256 = sha256Hex(document.bytes)
document.byteLength = document.bytes.byteLength
const [desktopVersion, protocolWindow] = await Promise.all([
readDesktopVersion(),
readProtocolWindow()
])
await writeMobileWebBundleTree({ outDir, written: assets, desktopVersion, protocolWindow })
await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).rejects.toThrow('is stale')
})
},
240_000
)
})
describe('the CRLF guard', () => {
it('covers the three trees whose bytes reach the buildId', () => {
expect(MOBILE_WEB_APP_SOURCE_DIRS.map((dir) => dir.slice(projectDir.length))).toEqual([
join('mobile', 'web-entry'),
join('mobile', 'app'),
join('mobile', 'src')
])
})
it('fails on a CRLF source file', async () => {
await withScratch(async (scratch) => {
await writeFile(join(scratch, 'route.tsx'), 'export default null\r\n', 'utf8')
await expect(assertNoCarriageReturnsInSource(scratch)).rejects.toThrow('CRLF')
})
})
it('exempts the binary assets .gitattributes pins -text', async () => {
await withScratch(async (scratch) => {
await writeFile(join(scratch, 'icon.ttf'), Buffer.from([0x00, 0x0d, 0x0a]))
await writeFile(join(scratch, 'shot.png'), Buffer.from([0x0d]))
await expect(assertNoCarriageReturnsInSource(scratch)).resolves.toBeUndefined()
})
})
it('exempts the gitignored generated webview engine modules', async () => {
await withScratch(async (scratch) => {
await writeFile(join(scratch, 'engine.generated.ts'), 'export const X = "a\r\n"', 'utf8')
await expect(assertNoCarriageReturnsInSource(scratch)).resolves.toBeUndefined()
})
})
})
describe('naming an output by its bytes', () => {
it('refuses two outputs that name each other', () => {
const emitted = (text) => new TextEncoder().encode(text)
const metafile = {
outputs: {
'dist/a.js': { imports: [{ path: 'dist/b.js', kind: 'import-statement' }] },
'dist/b.js': { imports: [{ path: 'dist/a.js', kind: 'import-statement' }] }
}
}
// Neither name can be final before the other is, so a cycle has no content hash to reach.
// esbuild's splitting emits a DAG; this is the hard stop for the day it does not.
expect(() =>
renameOutputsByContent(metafile, [
{ path: 'dist/a.js', contents: emitted('import "/assets/b.js"') },
{ path: 'dist/b.js', contents: emitted('import "/assets/a.js"') }
])
).toThrow(/output cycle/)
})
it('refuses a route it cannot find an output for', async () => {
await withScratch(async (scratch) => {
const module = join(scratch, 'index.tsx')
await writeFile(module, 'export default function Route() { return null }\n')
// The metafile is the only thing that knows which chunk holds a route. Without this the
// route reaches the manifest naming a chunk of undefined, which the phone fetches as a 404.
expect(() =>
routeChunkNames({ outputs: {} }, [{ key: './index.tsx', module }], new Map())
).toThrow(/\.\/index\.tsx reached no output/)
})
})
})
+36 -8
View File
@@ -16,7 +16,14 @@ const CONTENT_TYPE_BY_EXTENSION = {
css: 'text/css; charset=utf-8',
html: 'text/html; charset=utf-8',
js: 'text/javascript; charset=utf-8',
png: 'image/png'
png: 'image/png',
// The Phase C app bundle emits images as same-origin assets rather than data: URLs, which the
// shell's img-src 'self' refuses. Fonts are absent by design: the policy sets font-src 'none'.
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml'
}
/**
@@ -41,11 +48,11 @@ export function computeMobileWebBundleBuildId(assets) {
return createHash('sha256').update(serializeMobileWebBundleAssets(assets), 'utf8').digest('hex')
}
function sha256Hex(bytes) {
export function sha256Hex(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function contentTypeForExtension(extension) {
export function contentTypeForExtension(extension) {
const contentType = CONTENT_TYPE_BY_EXTENSION[extension]
if (!contentType) {
throw new Error(`[build-mobile-web-bundle] no content type registered for .${extension}`)
@@ -65,7 +72,7 @@ function readIntegerConstant(source, name) {
* Parsed rather than imported because protocol-version.ts is TypeScript and this script runs on
* bare node during packaging, before any build output exists.
*/
async function readProtocolWindow() {
export async function readProtocolWindow() {
const source = await readFile(join(projectDir, 'src', 'shared', 'protocol-version.ts'), 'utf8')
return {
runtimeProtocolVersion: readIntegerConstant(source, 'RUNTIME_PROTOCOL_VERSION'),
@@ -77,7 +84,7 @@ async function readProtocolWindow() {
}
}
async function readDesktopVersion() {
export async function readDesktopVersion() {
const packageJson = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'))
if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
throw new Error('[build-mobile-web-bundle] root package.json has no version')
@@ -124,7 +131,7 @@ async function transformEntries(protocolWindow, desktopVersion) {
return { script, stylesheet }
}
function hashedAsset(bytes, extension) {
export function hashedAsset(bytes, extension) {
const sha256 = sha256Hex(bytes)
return {
bytes,
@@ -172,7 +179,27 @@ export async function buildMobileWebBundle({ outDir = defaultOutDir } = {}) {
contentType: contentTypeForExtension('html')
}
const written = [indexAsset, ...hashed]
return writeMobileWebBundleTree({
outDir,
written: [indexAsset, ...hashed],
desktopVersion,
protocolWindow
})
}
/**
* Manifest assembly and the on-disk write, shared by the Phase A bootstrap bundle and the Phase C
* app bundle so both produce the same manifest shape the contract module and verifier read.
*/
export async function writeMobileWebBundleTree({
outDir,
written,
desktopVersion,
protocolWindow,
// Empty for the Phase A bootstrap, which carries no route tree at all: a shell reading it finds
// no screen listed and renders every route natively, which is what it already does.
routes = []
}) {
const assets = written
.map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType }))
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
@@ -184,7 +211,8 @@ export async function buildMobileWebBundle({ outDir = defaultOutDir } = {}) {
runtimeProtocolVersion: protocolWindow.runtimeProtocolVersion,
entrypoint: MOBILE_WEB_BUNDLE_ENTRYPOINT,
totalBytes: assets.reduce((total, asset) => total + asset.byteLength, 0),
assets
assets,
routes
}
// Why a full clear: a stale asset left from an earlier build would ship unreferenced inside asar.
@@ -59,10 +59,14 @@ describe('buildMobileWebBundle', () => {
'runtimeProtocolVersion',
'entrypoint',
'totalBytes',
'assets'
'assets',
'routes'
])
expect(manifest.schemaVersion).toBe(1)
expect(manifest.entrypoint).toBe('index.html')
// The bootstrap bundle carries no route tree, so a shell reading this one finds no screen
// listed and renders every route natively.
expect(manifest.routes).toEqual([])
const packageJson = JSON.parse(
await readFile(new URL('../../package.json', import.meta.url), 'utf8')
)
@@ -32,25 +32,37 @@ describe('CI dependency download caches', () => {
describe('release install targets', () => {
const macCpuFlag = '--cpu=current,x64,arm64'
// Both shapes: `run:` steps and steps wrapped in nick-fields/retry (`with.command`).
const installCommand = (step) => step.with?.command ?? step.run
const installSteps = (name) =>
Object.values(workflow(name).jobs)
.flatMap((job) => job.steps ?? [])
.map((step) => step.with?.command ?? step.run)
.filter((command) => typeof command === 'string' && command.includes('pnpm install '))
.filter((step) => installCommand(step)?.includes('pnpm install '))
const installCommands = (name) => installSteps(name).map(installCommand)
it.each(['adhoc-mac-build', 'daily-mac-build', 'hourly-mac-build', 'release-mac-build'])(
'%s installs both mac CPU variants for the x64+arm64 package config',
(name) => {
const installs = installSteps(name)
const installs = installCommands(name)
expect(installs.length).toBeGreaterThan(0)
expect(installs.some((command) => command.includes(macCpuFlag))).toBe(true)
}
)
// A transient `read ECONNRESET` fetching this Node version's headers for
// native/windows-registry's node-gyp rebuild failed a blocking golden gate and the cut.
it('retries every release-cut install so one transient download cannot fail a cut', () => {
const installs = installSteps('release-cut')
expect(installs.length).toBeGreaterThan(0)
for (const step of installs) {
expect(step.uses).toBe('nick-fields/retry@v4')
expect(step.with.max_attempts).toBeGreaterThan(1)
}
})
it.each(['release-cut', 'dev-channel-win-build', 'windows-signing-rehearsal'])(
'%s keeps installs scoped to the runner host',
(name) => {
const installs = installSteps(name)
const installs = installCommands(name)
expect(installs.length).toBeGreaterThan(0)
for (const command of installs) {
expect(command).not.toContain('--os=')
@@ -0,0 +1,34 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
/**
* Set by the one CI job that installs mobile dependencies, so a broken install there fails the
* job instead of quietly skipping every test that would have caught it.
*/
export const MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV = 'ORCA_MOBILE_WEB_APP_DEPS_REQUIRED'
const SKIP_NOTICE =
'[mobile-web-app] skipping the bundling tests: mobile/node_modules/react-native-web is absent. ' +
'They run for real in pr.yml, in the mobile_web_app job, which installs mobile dependencies.'
/**
* Bundling the Route A page resolves react-native-web out of mobile/node_modules, which the
* sharded `test` job deliberately does not install. Tests that bundle ask this first.
*/
export function mobileWebAppDependenciesPresent(
modulePath = join(projectDir, 'mobile', 'node_modules', 'react-native-web')
) {
if (existsSync(modulePath)) {
return true
}
if (process.env[MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV] === '1') {
throw new Error(
`[mobile-web-app] ${modulePath} is missing in a job that installs mobile dependencies`
)
}
console.log(SKIP_NOTICE)
return false
}
@@ -0,0 +1,593 @@
import { createServer } from 'node:http'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { fileURLToPath } from 'node:url'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
// Why a real browser: the route tree is handed to expo-router's own ExpoRoot through a synthesized
// RequireContext. Nothing short of mounting it proves that object is the shape ExpoRoot reads.
const HOST_ROUTE = '/h/render-check-host'
// What the double answers `ready` with. Asserted on the document, so a page that mounted against
// some other session, or against none, fails here rather than on a phone.
const SHELL_SESSION_ID = 'render-check-session'
const SHELL_BUILD_ID = 'render-check-build'
// The host the shell opened the page for. Without it `expo-secure-store` is {} on web and the list
// paints "Host not found" over a host that is right there.
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
// The sharded `test` job does not install mobile dependencies, so the page cannot be built there.
// The CSP suite below needs none of them and still runs. pr.yml's mobile_web_app job runs both.
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let routeChunks = {}
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
/**
* Chunk paths the server answers with a module that throws on evaluation.
*
* The one way to reproduce the failure the boundary exists for: a route chunk that never arrives
* intact. Building a second bundle around a throwing route would test a synthetic tree; poisoning
* one file of the real bundle keeps everything else exactly what ships.
*/
const poisonedChunks = new Set()
const POISON_MESSAGE = 'render check poisoned this route chunk'
/**
* Both CSP constants are a list of quoted directives with `//` comments between them, and those
* comments quote directive text. Dropping comment lines first is what keeps a comment out of the
* header this test serves.
*/
export function parseCspDirectives(source, startMarker, endMarker) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker)
if (start === -1 || end < start) {
throw new Error(`could not find ${startMarker} .. ${endMarker}`)
}
const body = source
.slice(start, end)
.split('\n')
.filter((line) => !line.trimStart().startsWith('//'))
.join('\n')
const directives = [...body.matchAll(/"([^"]+)"/g)].map((match) => match[1])
if (directives.length < 10) {
throw new Error('could not parse the shell CSP')
}
return directives.join('; ')
}
/**
* The envelope version the page speaks, read from the contract rather than written down twice. A
* bumped `v` would otherwise reach this file as a 30s timeout naming nothing.
*/
async function readBridgeProtocolVersion() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
'utf8'
)
const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_PROTOCOL_VERSION')
}
return Number(match[1])
}
/** The grant the shell offers every page, read from the same source for the same reason. */
async function readBridgeFaultGrant() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
'utf8'
)
const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_FAULT_GRANT')
}
return match[1]
}
/**
* The shell's half of the bridge, as the page's channel sees it.
*
* The entry mounts nothing until `init` lands, so a render check with no shell renders no route at
* all. This answers `ready` and refuses everything else: a real reply would make this file the
* place domain behaviour is decided, and every screen below already has a state for an RPC that
* failed. The one message that matters here is the one that lets the tree mount.
*/
function installShellDouble({ version, sessionId, buildId, route, host, storage, faultGrant }) {
// Where the page's own fault reports land. Read back after the render, so a route that threw
// under the boundary names itself instead of timing out as a page that never mounted.
globalThis.__orcaRenderCheckFaults = []
const channel = {
postMessage: (json) => {
const frame = JSON.parse(json)
const answer = (message) => {
// A microtask, not a task: the page posts `ready` while its script is still running, and
// this keeps the answer behind it without moving a timer the page's backoff reads.
queueMicrotask(() => {
channel.onmessage?.({ data: JSON.stringify(message) })
})
}
if (frame.type === 'ready') {
answer({
v: version,
type: 'init',
sessionId,
buildId,
connection: {
state: 'connected',
reconnectAttempt: 0,
lastConnectedAt: 1,
lastInboundAt: 1,
generation: 0
},
grants: {
rpc: { maxPendingRequests: 64, maxSubscriptions: 32 },
native: [faultGrant]
},
// Omitted for a shell too old to name one, which is the case the page has a panel for.
...(route === null ? {} : { route }),
...(host === null ? {} : { host }),
storage
})
return
}
if (frame.type === 'notify' && frame.name === faultGrant) {
globalThis.__orcaRenderCheckFaults.push(frame.error.message)
return
}
if (frame.type === 'request' || frame.type === 'subscribe') {
answer({
v: version,
type: 'error',
id: frame.id,
error: {
category: 'RenderCheckShellDouble',
message: 'the render check answers no RPC',
isRpcDeliveryUnknown: false
}
})
}
},
onmessage: null
}
globalThis.orcaBridge = channel
}
/**
* The shipped policy, read from the Kotlin source so this test cannot drift from what the shell
* actually sends. Parsed rather than imported: the constant lives in a JVM module.
*/
async function readShellCsp() {
const source = await readFile(
join(
projectDir,
'mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt'
),
'utf8'
)
return parseCspDirectives(source, 'listOf(', ').joinToString')
}
beforeAll(async () => {
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
if (!bundles) {
return
}
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-render-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const { outDir } = built
routeChunks = built.routeChunks
server = createServer((request, response) => {
const path = new URL(request.url, 'http://localhost').pathname
// A browser asks for this on its own and the shell's WebView never does. The bundle carries
// no icon, so a 404 would put a console error in every check that runs against a full Chrome
// -- which is what CI resolves -- and none against the bundled headless shell.
if (path === '/favicon.ico') {
response.writeHead(204)
response.end()
return
}
// A route path serves the entrypoint and the page routes client-side. A path naming a file
// has to come out of the bundle or 404, the same as the shell's manifest map: answering it
// with the document instead would hide a publicPath the script cannot fetch from.
const namesAFile = path.slice(path.lastIndexOf('/')).includes('.')
const file = namesAFile ? path.slice(1) : 'index.html'
readFile(join(outDir, file)).then(
(real) => {
// The real bytes with a throw in front: the module still links, so the importer resolves
// every export it asked for and then evaluation throws. A body replaced outright fails at
// link instead, which is a different failure from the one the boundary is here for.
const bytes = poisonedChunks.has(path)
? `throw new Error(${JSON.stringify(POISON_MESSAGE)});\n${real.toString('utf8')}`
: real
const headers = {
'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html'
}
// The document carries the shell's real policy, so a directive the page violates fails
// here rather than on a phone. Assets carry none, exactly as the native handler does.
if (file === 'index.html' && cspHeader) {
headers['content-security-policy'] = cspHeader
}
response.writeHead(200, headers)
response.end(bytes)
},
() => {
response.writeHead(404)
response.end()
}
)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
origin = `http://127.0.0.1:${String(server.address().port)}`
// CI runs this against the runner's Google Chrome rather than paying for a browser download,
// the same reason and the same override shape as the orcad browser-provider job.
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 180_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
// expo-router's Unmatched screen mounts cleanly and paints text, so "no errors, some html" stays
// green with every host route unreachable. Each route below names content only it can produce.
const UNMATCHED = 'Unmatched Route'
/**
* A page with every signal the checks below read: uncaught errors, console errors, and the script
* paths the browser actually fetched. The last one is how a client-side navigation proves it
* pulled the next route's chunk rather than painting out of what the entry already had.
*
* No `shellRoute` installs no double at all, which is the page that never mounts; a null one
* installs a shell that named no screen.
*/
async function openPage({ shellRoute, shellHost = SHELL_HOST, shellStorage = {} } = {}) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
if (shellRoute !== undefined) {
// At document start, where the native shell installs the real channel: the entry reads it
// while its own script runs, so a channel added after `load` would already be too late.
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: shellRoute,
host: shellHost,
storage: shellStorage,
faultGrant
})
}
const errors = []
const scripts = []
let reportUncaught = () => {}
// An uncaught error from the entry means nothing will ever mount. Racing it against the wait
// reports that error in a second instead of a 30s timeout that names nothing -- which is what a
// native-only route module, throwing at import before React runs, looks like from here.
// Resolved rather than rejected: this one settles during goto, before anything awaits it.
const uncaught = new Promise((resolve) => {
reportUncaught = resolve
})
page.on('pageerror', (error) => {
errors.push(`${error.name}: ${error.message}`)
reportUncaught(error)
})
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (response.status() === 200 && path.endsWith('.js')) {
scripts.push(path)
}
})
return { page, errors, scripts, uncaught }
}
/**
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
* the route manifest defers every screen behind `import()`, so the entry's `mounted` signal lands
* while the route's chunk is still being fetched and the body is briefly empty. Waiting for the
* string the caller is about to assert is what makes the check about the route and not the timing.
*/
async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
const named = (cause, what) =>
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`, { cause })
const race = async (wait) =>
Promise.race([
wait.then(
() => null,
(error) => error
),
uncaught
])
// The entry's own signal, not "#root has children": an error boundary or a half-painted tree
// also fills #root, and this only lands once expo-router's tree below the wrapper has committed.
// Polled on a timer rather than Playwright's default animation frames, which a page that never
// paints never delivers.
const cause = await race(
page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 30_000,
polling: 250
})
)
if (cause) {
const state = await page.evaluate(
() => document.documentElement.dataset.orcaWebEntry ?? 'absent'
)
throw named(cause, `never mounted (entry ${state})`)
}
const paintCause = await race(
page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 30_000,
polling: 250
})
)
if (paintCause) {
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
}
// Folded into the errors the caller already asserts empty: a throw the boundary caught paints
// nothing and logs nothing a `pageerror` listener hears, so this is the only place it shows up.
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
/**
* Opens the document the way the shell does — at `/`, the one path it serves — and lets the page
* route itself from what the double names. Navigating straight to the route would hide exactly the
* step this check exists to prove.
*/
async function render(route, awaitText, { shellRoute = { pathname: route }, ...shell } = {}) {
const opened = await openPage({ shellRoute, ...shell })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
const text = await opened.page.evaluate(() => document.body.innerText)
// What the page believes it is: read off the document rather than off the double, so a tree that
// mounted without a session, or against a session it invented, is not a passing render.
const session = await opened.page.evaluate(() => ({
sessionId: document.documentElement.dataset.orcaWebSessionId ?? null,
buildId: document.documentElement.dataset.orcaWebBuildId ?? null
}))
// The document is served at "/" and the page rewrites its own path before it renders; without
// that, every route below would be expo-router's Unmatched screen.
const url = await opened.page.evaluate(() => location.pathname + location.search)
await opened.page.close()
// A CSP refusal reaches the page as a console error, so the caller's empty-errors assertion is
// also the policy assertion; name it here so a failure says which one broke.
return {
errors: opened.errors,
cspErrors: opened.errors.filter((entry) => entry.includes('Content Security Policy')),
text,
session,
url
}
}
/** The entry's state and what it painted, for a page that is never going to mount a route tree. */
async function renderWithoutTree({ shellRoute } = {}) {
const { page, errors } = await openPage({ shellRoute })
// Read straight after `load` and not polled: the entry decides this synchronously, inside the
// script `load` waits for, so a state that is not settled by now is never going to settle.
await page.goto(`${origin}/`, { waitUntil: 'load' })
const entry = await page.evaluate(() => document.documentElement.dataset.orcaWebEntry ?? 'absent')
const rootChildren = await page.evaluate(() => document.getElementById('root').childElementCount)
const text = await page.evaluate(() => document.body.innerText)
const url = await page.evaluate(() => location.pathname + location.search)
await page.close()
return { entry, errors, rootChildren, text, url }
}
describe('the shell policy this page is tested under', () => {
it('is the same on both platforms, so one render check covers both', async () => {
const swift = await readFile(
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
'utf8'
)
expect(parseCspDirectives(swift, 'static let header = [', '].joined')).toBe(cspHeader)
})
it('reads directives from the source and not from the comments around them', () => {
const source = [
'static let header = [',
" // React Native Web needs \"style-src 'self' 'unsafe-inline'\" and nothing more.",
' "default-src \'none\'",',
' "script-src \'self\'",',
" \"style-src 'self' 'unsafe-inline'\",",
' "img-src \'self\'",',
' "connect-src \'self\'",',
' "worker-src \'none\'",',
' "frame-src \'none\'",',
' "child-src \'none\'",',
' "object-src \'none\'",',
' "base-uri \'none\'",',
' "form-action \'none\'",',
' "frame-ancestors \'none\'"',
'].joined'
].join('\n')
const parsed = parseCspDirectives(source, 'static let header = [', '].joined')
expect(parsed.split('; ')[0]).toBe("default-src 'none'")
expect(parsed.split('; ').filter((entry) => entry.includes('unsafe-inline'))).toEqual([
"style-src 'self' 'unsafe-inline'"
])
})
it('still refuses inline script, which is the directive that matters', () => {
expect(cspHeader).toContain("script-src 'self';")
expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'")
})
})
describeRender('the page server this check runs against', () => {
it('404s a file path the bundle does not contain', async () => {
// Without this the document answers every path, and a publicPath the script cannot fetch
// from still renders, because the script is fetched from the one prefix that is served.
expect((await fetch(`${origin}/wrong-prefix/entry.js`)).status).toBe(404)
expect((await fetch(`${origin}/assets/not-a-real-hash.js`)).status).toBe(404)
})
it('answers the icon a browser asks for without an error', async () => {
expect((await fetch(`${origin}/favicon.ico`)).status).toBe(204)
})
it('still serves the document at every route depth', async () => {
for (const route of ['/', HOST_ROUTE, `${HOST_ROUTE}/tasks`]) {
const response = await fetch(`${origin}${route}`)
expect(response.status, route).toBe(200)
expect(await response.text(), route).toContain('<div id="root">')
}
})
})
describeRender('the Route A page in a real browser', () => {
it('mounts the worktree list route, not the unmatched screen', async () => {
const { errors, cspErrors, text, session, url } = await render(HOST_ROUTE, SHELL_HOST.name)
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
// The tree that mounted is the one the shell handed a session to, and it says which.
expect(session).toEqual({ sessionId: SHELL_SESSION_ID, buildId: SHELL_BUILD_ID })
// The document was served at `/`; the page put itself on the route the shell named.
expect(url).toBe(HOST_ROUTE)
// The host the shell named, read through host-store.web.ts off `init.host`. Only that route's
// own component names the host; "Host not found" is what it paints without one.
expect(text).toContain(SHELL_HOST.name)
expect(text).not.toContain('Host not found')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('routes a nested dynamic segment through the same context', async () => {
const { errors, cspErrors, text, session } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
expect(session.sessionId).toBe(SHELL_SESSION_ID)
// app/h/[hostId]/tasks.tsx paints its header and its GitHub filter row.
expect(text).toContain('Tasks')
expect(text).toContain('Issues')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('renders the unmatched route rather than crashing on a path with no module', async () => {
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`, UNMATCHED)
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
// Asserted positively so the two negatives above are known to discriminate.
expect(text).toContain(UNMATCHED)
}, 60_000)
it('carries the params the shell named into the url the screen reads', async () => {
const { errors, url } = await render(HOST_ROUTE, SHELL_HOST.name, {
shellRoute: { pathname: HOST_ROUTE, params: { from: 'render check' } }
})
expect(errors).toEqual([])
expect(url).toBe(`${HOST_ROUTE}?from=render+check`)
}, 60_000)
it('paints the not-found state when the shell named no host, which is what makes the row real', async () => {
const { errors, text } = await render(HOST_ROUTE, 'Host not found', { shellHost: null })
expect(errors).toEqual([])
expect(text).toContain('Host not found')
expect(text).not.toContain(SHELL_HOST.name)
}, 60_000)
it('mounts nothing at all when no shell answered, which is what makes the rest real', async () => {
// Without this the checks above would pass against a page that ignores `init` entirely.
const { entry, errors, rootChildren } = await renderWithoutTree()
expect(entry).toBe('unbridged')
expect(rootChildren).toBe(0)
expect(errors).toEqual([])
}, 60_000)
it('says to update the app when the shell that opened it named no screen', async () => {
const { entry, errors, text, url } = await renderWithoutTree({ shellRoute: null })
expect(entry).toBe('shell-too-old')
expect(errors).toEqual([])
expect(text).toContain('Update Orca to open this workspace')
// Never the route tree at `/`: that is the Unmatched screen with a worse explanation.
expect(text).not.toContain(UNMATCHED)
expect(url).toBe('/')
}, 60_000)
it('tells the shell when a route chunk throws, rather than sitting on a blank page', async () => {
const chunk = routeChunks['./h/[hostId]/index.tsx']
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
poisonedChunks.add(`/assets/${chunk}`)
try {
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
const reported = await opened.page
.waitForFunction(
() => {
const faults = globalThis.__orcaRenderCheckFaults ?? []
return faults.length > 0 ? faults : null
},
{ timeout: 30_000, polling: 250 }
)
.then((handle) => handle.jsonValue())
// The message the poisoned module threw, carried across the bridge as the shell sees it. A
// boundary that caught the throw and reported something else would pass an "any fault" check.
expect(reported.join(' | ')).toContain(POISON_MESSAGE)
// And the screen never painted. The router's own shell commits before the deferred chunk
// rejects, so the entry does reach `mounted`; what the boundary takes away is everything
// below it, which is the difference between a reported failure and a blank page nobody hears.
const text = await opened.page.evaluate(() => document.body.innerText)
expect(text).not.toContain('Host not found')
expect(text).not.toContain(UNMATCHED)
await opened.page.close()
} finally {
poisonedChunks.delete(`/assets/${chunk}`)
}
}, 60_000)
it("fetches the next route's chunks on a client-side navigation", async () => {
const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } })
const { page, errors, scripts } = opened
await page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, SHELL_HOST.name)
const loadedForFirstRoute = [...scripts]
// What the shell will do in C1.2: the document is fetched once and every later route is a
// history entry, so the tasks screen can only arrive as a chunk fetched now.
await page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, `${HOST_ROUTE}/tasks`)
await waitForRoute(opened, `${HOST_ROUTE}/tasks`, 'Issues')
expect(new URL(page.url()).pathname).toBe(`${HOST_ROUTE}/tasks`)
const fetchedOnNavigation = scripts.filter((path) => !loadedForFirstRoute.includes(path))
// Not "some script arrived": the chunk the builder put the tasks route in, named by the
// builder rather than guessed from the bytes, which is the only thing that says the route
// came over the wire now and not out of what the first route had already loaded.
const tasksChunk = routeChunks['./h/[hostId]/tasks.tsx']
expect(tasksChunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
expect(fetchedOnNavigation, scripts.join(' ')).toContain(`/assets/${tasksChunk}`)
expect(loadedForFirstRoute).not.toContain(`/assets/${tasksChunk}`)
const text = await page.evaluate(() => document.body.innerText)
expect(text).toContain('Tasks')
expect(text).not.toContain(UNMATCHED)
expect(errors).toEqual([])
await page.close()
}, 60_000)
})
@@ -0,0 +1,204 @@
import { readdir } from 'node:fs/promises'
import { extname, join, relative } from 'node:path'
import * as esbuild from 'esbuild'
/** The route subtree the page mounts. The rest of mobile/app is native-only (pairing, settings). */
export const MOBILE_WEB_APP_ROUTE_ROOT = 'h'
const ROUTE_FILE = /\.[tj]sx?$/
const NOT_A_ROUTE = /(\.(test|spec|d)\.|\+api\.|\+middleware\.)/
// esbuild's resolveExtensions order, which only applies to an extensionless import. Routes are
// imported by full path, so the web sibling is picked here instead.
const WEB_SIBLING_EXTENSIONS = ['.web.tsx', '.web.ts', '.web.jsx', '.web.js']
function webSiblingOf(name, siblings) {
const stem = name.slice(0, name.length - extname(name).length)
return WEB_SIBLING_EXTENSIONS.map((extension) => `${stem}${extension}`).find((candidate) =>
siblings.has(candidate)
)
}
/**
* Every route in the mounted subtree, sorted by key so the generated module is a pure function of
* the tree on disk. `key` is the require.context key expo-router names the screen by, always the
* native filename; `module` is the file the bundle imports, which is the `.web.*` sibling when one
* exists. They differ so a web override changes the code without moving the URL.
*/
export async function collectMobileWebAppRoutes(appDir, routeRoot = MOBILE_WEB_APP_ROUTE_ROOT) {
const routes = []
async function walk(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const siblings = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name))
for (const entry of entries) {
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
await walk(entryPath)
} else if (
entry.isFile() &&
ROUTE_FILE.test(entry.name) &&
!NOT_A_ROUTE.test(entry.name) &&
!entry.name.includes('.web.')
) {
const override = webSiblingOf(entry.name, siblings)
routes.push({
key: `./${relative(appDir, entryPath).split('\\').join('/')}`,
module: override ? join(directory, override) : entryPath
})
}
}
}
await walk(join(appDir, routeRoot))
if (routes.length === 0) {
throw new Error(`[mobile-web-app] no routes under ${join(appDir, routeRoot)}`)
}
return routes.sort((left, right) => (left.key < right.key ? -1 : 1))
}
/**
* The URL pattern expo-router gives a route key, or null for a file that is not a screen.
*
* Dynamic segments are kept as written (`[hostId]`), because what this feeds is a pattern the shell
* matches a concrete route against, not a URL anyone visits. `index` names its own directory, and a
* file whose name starts with `_` is a layout rather than a screen.
*/
export function routePathnameFromKey(key) {
const segments = key.replace(/^\.\//, '').replace(ROUTE_FILE, '').split('/')
const last = segments.at(-1)
if (last === undefined || last.startsWith('_')) {
return null
}
if (last === 'index') {
segments.pop()
}
return `/${segments.join('/')}`
}
/** The require.context keys alone, for callers that only need the route names. */
export async function collectMobileWebAppRouteKeys(appDir, routeRoot = MOBILE_WEB_APP_ROUTE_ROOT) {
return (await collectMobileWebAppRoutes(appDir, routeRoot)).map((route) => route.key)
}
/**
* The RequireContext behaviour, kept as source so a test can evaluate it against a fake `modules`
* without bundling the real route tree. Inlined into the generated module because that module is
* bundled for the browser and cannot import from config/scripts.
*/
export const ROUTE_CONTEXT_SOURCE = `const keys = Object.keys(modules)
function routeContext(id) {
if (!Object.prototype.hasOwnProperty.call(modules, id)) {
throw new Error('[orca-mobile-web-app] no route module for ' + id)
}
return modules[id]
}
routeContext.keys = () => keys.slice()
routeContext.resolve = (id) => {
if (!Object.prototype.hasOwnProperty.call(modules, id)) {
throw new Error('[orca-mobile-web-app] cannot resolve route ' + id)
}
return id
}
routeContext.id = 'orca-mobile-web-app-routes'`
/**
* esbuild has no `require.context`, so the builder synthesizes the RequireContext expo-router's
* own ExpoRoot consumes. The context itself stays synchronous — expo-router reads `keys()` to
* build the route tree before anything renders — and only the screen behind each key is deferred,
* through the `import()` esbuild splits into a per-route chunk.
*
* `default` is the whole module: a lazy module cannot answer `unstable_settings` or
* `ErrorBoundary`, which expo-router reads synchronously off the namespace. No route in the
* mounted subtree exports either, and `assertRoutesCarryNoSynchronousExports` below fails the
* build rather than emitting a page that mounts with the export silently gone.
*/
export function renderMobileWebAppRouteManifest(routes) {
const entryLines = routes.map(
({ key, module }) =>
` [${JSON.stringify(key)}]: { default: lazy(() => import(${JSON.stringify(module)})) }`
)
return `import { lazy } from "react"
const modules = {
${entryLines.join(',\n')}
}
${ROUTE_CONTEXT_SOURCE}
export default routeContext
`
}
/**
* The expo-router exports a route module may carry besides `default`. Read off the namespace while
* the tree is built, so a lazy module would drop them silently rather than fail.
*/
export const ROUTE_MODULE_SYNCHRONOUS_EXPORTS = ['unstable_settings', 'ErrorBoundary']
/**
* How esbuild has to read a route's own source. React Native ships untranspiled JSX inside `.js`,
* including expo-router's own build/, so a `.js` route that carries JSX is a syntax error without
* this. The builder spreads the same table into its own loaders, which is what keeps the guard
* reading a route exactly as the bundle does.
*/
export const ROUTE_SOURCE_LOADERS = { '.js': 'jsx' }
/** esbuild's own normalized output for a re-export whose names it did not resolve. */
const STAR_REEXPORT = /^export \* from "(.*)";$/gm
/**
* Which of those a route module puts on its namespace, and which specifiers it re-exports whole.
*
* Read from esbuild's parse rather than the source text, because the name reaching the namespace
* is not the name any declaration carries: `export { settings as unstable_settings }`,
* `export class ErrorBoundary` and `export { ErrorBoundary } from './boundary'` are all invisible
* to a pattern over declarations, and all three break the lazy manifest the same way.
*
* `bundle` is off: this asks what one module exports, and following its imports would pull the
* whole app in to answer. The cost is `export * from x`, whose names esbuild cannot enumerate
* without reading x; those are returned separately so the caller fails closed instead of reading
* an unresolved star as clean.
*/
export async function routeModuleSynchronousExports(modulePath) {
const result = await esbuild.build({
entryPoints: [modulePath],
bundle: false,
write: false,
format: 'esm',
metafile: true,
loader: ROUTE_SOURCE_LOADERS,
// Never written; it only names the single output the metafile is keyed by.
outdir: 'route-exports',
logLevel: 'silent'
})
const [output] = Object.values(result.metafile.outputs)
return {
named: (output?.exports ?? []).filter((name) =>
ROUTE_MODULE_SYNCHRONOUS_EXPORTS.includes(name)
),
starExports: [...result.outputFiles[0].text.matchAll(STAR_REEXPORT)].map((match) => match[1])
}
}
/**
* Fails the build on any route the lazy manifest would strip an export from. Runs in the build
* and not only in a test, because what it prevents is a page that mounts with `ErrorBoundary`
* gone: the throw escapes to the window, nothing paints, and no log says why.
*
* 14 parses of one module each, so it costs a fraction of the bundle it guards.
*/
export async function assertRoutesCarryNoSynchronousExports(routes) {
const read = await Promise.all(routes.map(({ module }) => routeModuleSynchronousExports(module)))
routes.forEach(({ key }, index) => {
const { named, starExports } = read[index]
if (named.length > 0) {
throw new Error(
`[mobile-web-app] ${key} exports ${named.join(', ')}, which expo-router reads off the ` +
'module namespace while it builds the route tree. A route behind import() cannot answer ' +
'it, so this route needs a static import or the export has to move to a layout.'
)
}
if (starExports.length > 0) {
throw new Error(
`[mobile-web-app] ${key} re-exports all of ${starExports.join(', ')}, so whether it ` +
`carries ${ROUTE_MODULE_SYNCHRONOUS_EXPORTS.join(' or ')} cannot be read without ` +
'bundling it. Name the exports instead of re-exporting the module whole.'
)
}
})
}
@@ -0,0 +1,242 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_APP_ROUTE_ROOT,
ROUTE_CONTEXT_SOURCE,
ROUTE_MODULE_SYNCHRONOUS_EXPORTS,
collectMobileWebAppRouteKeys,
collectMobileWebAppRoutes,
renderMobileWebAppRouteManifest,
routeModuleSynchronousExports
} from './mobile-web-app-route-manifest.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const appDir = join(projectDir, 'mobile', 'app')
// The sharded `test` job does not install mobile dependencies, so anything that runs esbuild over
// the route tree is skipped there and run for real in pr.yml's mobile_web_app job.
const itBundling = mobileWebAppDependenciesPresent() ? it : it.skip
async function withScratch(run) {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-routes-test-'))
try {
return await run(scratch)
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
describe('route manifest', () => {
it('collects the h/ subtree and nothing above it', async () => {
const keys = await collectMobileWebAppRouteKeys(appDir)
expect(keys.length).toBeGreaterThan(0)
for (const key of keys) {
expect(key.startsWith(`./${MOBILE_WEB_APP_ROUTE_ROOT}/`)).toBe(true)
}
// The native-only shell (pairing, settings, notifications) must not reach the page bundle.
expect(keys).not.toContain('./_layout.tsx')
expect(keys).not.toContain('./pair.tsx')
})
it('is sorted, so the generated module is a pure function of the tree', async () => {
const keys = await collectMobileWebAppRouteKeys(appDir)
expect(keys).toEqual([...keys].sort())
})
it('excludes test files and API routes', async () => {
// mobile/app holds none of these today, so assert the rule against a tree that does.
await withScratch(async (scratch) => {
const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(directory, { recursive: true })
for (const name of [
'index.tsx',
'index.test.tsx',
'index.spec.tsx',
'shape.d.ts',
'+api.ts',
'tokens+api.ts',
'+middleware.ts',
'notes.md'
]) {
await writeFile(join(directory, name), 'export default null\n', 'utf8')
}
expect(await collectMobileWebAppRouteKeys(scratch)).toEqual(['./h/index.tsx'])
})
expect(await collectMobileWebAppRouteKeys(appDir)).not.toContain('./h/_layout.test.tsx')
})
it('refuses an empty subtree rather than emitting a context with no routes', async () => {
await expect(collectMobileWebAppRouteKeys(appDir, 'does-not-exist')).rejects.toThrow()
})
it('emits one lazy import per key, and no static import of a route', () => {
const source = renderMobileWebAppRouteManifest([
{ key: './h/index.tsx', module: '/app/h/index.tsx' },
{ key: './h/_layout.tsx', module: '/app/h/_layout.tsx' }
])
expect(source).toContain('["./h/index.tsx"]: { default: lazy(() => import("/app/h/index.tsx"))')
expect(source).toContain(
'["./h/_layout.tsx"]: { default: lazy(() => import("/app/h/_layout.tsx"))'
)
// A static import is what collapses the split back into one chunk.
expect(source).not.toContain('import * as route')
expect(source.match(/import\(/g)).toHaveLength(2)
})
it('leaves the RequireContext itself synchronous', () => {
// expo-router calls keys() to build the route tree before anything renders, so the context
// may not be a promise; only the screen behind each key is deferred.
const source = renderMobileWebAppRouteManifest([
{ key: './h/index.tsx', module: '/app/h/index.tsx' }
])
expect(source).toContain('routeContext.keys = () => keys.slice()')
expect(source).not.toContain('async function routeContext')
expect(source).not.toContain('await import(')
})
it('has no route carrying an export a lazy module would swallow', async () => {
const routes = await collectMobileWebAppRoutes(appDir)
expect(routes.length).toBeGreaterThan(0)
for (const { module } of routes) {
const { named, starExports } = await routeModuleSynchronousExports(module)
// expo-router reads these off the namespace while it builds the tree, which a module behind
// import() cannot answer. Adding one to a page route needs a static import for that route.
expect(named, `${module} exports ${named.join(', ')}`).toEqual([])
expect(starExports, `${module} re-exports all of ${starExports.join(', ')}`).toEqual([])
}
})
// Each of these puts the name on the namespace without declaring it, which is why the guard
// reads esbuild's parse instead of the source text.
it('reads the names off the namespace, not off a declaration', async () => {
expect(ROUTE_MODULE_SYNCHRONOUS_EXPORTS).toEqual(['unstable_settings', 'ErrorBoundary'])
await withScratch(async (scratch) => {
const exportsOf = async (name, source) => {
const file = join(scratch, name)
await writeFile(file, source, 'utf8')
return routeModuleSynchronousExports(file)
}
expect(
(await exportsOf('declared.tsx', 'export const unstable_settings = { anchor: "x" }\n'))
.named
).toEqual(['unstable_settings'])
expect(
(
await exportsOf(
'aliased.tsx',
'const settings = { anchor: "x" }\nexport { settings as unstable_settings }\n'
)
).named
).toEqual(['unstable_settings'])
expect((await exportsOf('classy.tsx', 'export class ErrorBoundary {}\n')).named).toEqual([
'ErrorBoundary'
])
expect(
(await exportsOf('forwarded.tsx', 'export { ErrorBoundary } from "./boundary"\n')).named
).toEqual(['ErrorBoundary'])
expect(
(await exportsOf('plain.tsx', 'export default function Route() { return null }\n')).named
).toEqual([])
})
}, 60_000)
it('refuses a star re-export rather than reading it as clean', async () => {
await withScratch(async (scratch) => {
const file = join(scratch, 'star.tsx')
// Nothing here says whether ./boundary exports ErrorBoundary, and answering would mean
// bundling the route. Reported as a violation so the guard fails closed.
await writeFile(file, 'export * from "./boundary"\nexport default null\n', 'utf8')
const { named, starExports } = await routeModuleSynchronousExports(file)
expect(named).toEqual([])
expect(starExports).toEqual(['./boundary'])
})
}, 60_000)
itBundling(
'reads a .js route that carries JSX, which the app tree allows',
async () => {
await withScratch(async (scratch) => {
// React Native ships untranspiled JSX inside .js, and collectMobileWebAppRoutes accepts a
// .js route, so the guard has to parse one the same way the bundle does.
const file = join(scratch, 'jsx-route.js')
await writeFile(
file,
'const boundary = () => <div />\nexport { boundary as ErrorBoundary }\nexport default () => <div />\n',
'utf8'
)
expect((await routeModuleSynchronousExports(file)).named).toEqual(['ErrorBoundary'])
})
},
60_000
)
it('imports a .web.tsx sibling under the native route key', async () => {
await withScratch(async (scratch) => {
const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'index.tsx'), 'export default function Route() {}\n')
expect(await collectMobileWebAppRoutes(scratch)).toEqual([
{ key: './h/index.tsx', module: join(directory, 'index.tsx') }
])
await writeFile(join(directory, 'index.web.tsx'), 'export default function Route() {}\n')
// The key is still the native filename, so the override changes the code and not the URL.
expect(await collectMobileWebAppRoutes(scratch)).toEqual([
{ key: './h/index.tsx', module: join(directory, 'index.web.tsx') }
])
})
})
})
describe('the synthesized RequireContext', () => {
const build = (modules) =>
new Function('modules', `${ROUTE_CONTEXT_SOURCE}; return routeContext`)(modules)
it('answers the four members expo-router reads', () => {
const context = build({ './h/index.tsx': { default: 'screen' } })
expect(context.keys()).toEqual(['./h/index.tsx'])
expect(context('./h/index.tsx')).toEqual({ default: 'screen' })
expect(context.resolve('./h/index.tsx')).toBe('./h/index.tsx')
expect(context.id).toBe('orca-mobile-web-app-routes')
})
it('hands out a copy of keys, so a caller cannot mutate the route tree', () => {
const context = build({ './h/index.tsx': {} })
context.keys().push('./injected.tsx')
expect(context.keys()).toEqual(['./h/index.tsx'])
})
it('throws rather than returning undefined for an unknown key', () => {
const context = build({ './h/index.tsx': {} })
expect(() => context('./missing.tsx')).toThrow('no route module')
expect(() => context.resolve('./missing.tsx')).toThrow('cannot resolve route')
})
it('does not answer inherited Object keys', () => {
const context = build({ './h/index.tsx': {} })
expect(() => context('constructor')).toThrow('no route module')
})
})
describe('the web entry', () => {
it('leaves the suspense boundary to expo-router', async () => {
const entry = await readFile(join(projectDir, 'mobile', 'web-entry', 'index.tsx'), 'utf8')
// A second boundary around the whole tree catches nothing the router has not already caught,
// and would only make the fallback ambiguous about which layer suspended.
expect(entry).not.toContain('Suspense')
})
itBundling('because the router already wraps every screen in one', async () => {
// The premise of the test above, read off the copy that is bundled: getQualifiedRouteComponent
// wraps each screen itself, which is what makes the lazy route manifest safe without a
// boundary of our own.
const useScreens = await readFile(
join(projectDir, 'mobile', 'node_modules', 'expo-router', 'build', 'useScreens.js'),
'utf8'
)
expect(useScreens).toContain('<react_1.default.Suspense fallback=')
})
})
@@ -0,0 +1,131 @@
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const mobileDir = join(projectDir, 'mobile')
const allowlistPath = join(mobileDir, 'web-entry', 'web-overrides.json')
// Every tree the app entry can resolve a .web.* sibling out of: src and web-entry and packages
// through the builder's resolveExtensions, app through the route manifest's own sibling
// preference. packages is in the list because the dictation hook imports the vendored
// @orca/expo-two-way-audio, whose web module then reaches the page.
const SCANNED = ['src', 'app', 'web-entry', 'packages']
const WEB_SIBLING = /\.web\.(tsx|ts|jsx|js)$/
async function listFiles(directory) {
const out = []
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.name === 'node_modules') {
continue
}
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
out.push(...(await listFiles(entryPath)))
} else if (entry.isFile()) {
out.push(entryPath)
}
}
return out
}
/** Takes the root so the census can be run against a scratch tree and shown to fail. */
export async function findWebSiblings(rootDir) {
const found = []
for (const tree of SCANNED) {
const directory = join(rootDir, tree)
if (!existsSync(directory)) {
continue
}
for (const file of await listFiles(directory)) {
if (WEB_SIBLING.test(file)) {
found.push(relative(rootDir, file).split('\\').join('/'))
}
}
}
return found.sort()
}
async function readAllowlist() {
return JSON.parse(await readFile(allowlistPath, 'utf8'))
}
async function exists(path) {
return readFile(path).then(
() => true,
() => false
)
}
async function withScratch(run) {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-overrides-'))
try {
return await run(scratch)
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
async function plant(scratch, file) {
await mkdir(join(scratch, file, '..'), { recursive: true })
await writeFile(join(scratch, file), 'export default null\n', 'utf8')
}
describe('mobile web app .web.* overrides', () => {
it('lists exactly the .web.* files on disk', async () => {
const { overrides } = await readAllowlist()
expect(overrides.map((entry) => entry.file).sort()).toEqual(await findWebSiblings(mobileDir))
})
it('gives every override a non-web sibling, so the native build still has a module', async () => {
const { overrides } = await readAllowlist()
for (const { file } of overrides) {
const native = join(mobileDir, file.replace('.web.', '.'))
// A .web.tsx may shadow a .tsx or a .ts; try both before failing.
const alternative = native.replace(/\.tsx$/, '.ts').replace(/\.jsx$/, '.js')
expect(
(await exists(native)) || (await exists(alternative)),
`${file} has no non-web sibling`
).toBe(true)
}
})
it('states a reason for every override', async () => {
const { overrides } = await readAllowlist()
for (const entry of overrides) {
expect(entry.reason.length, `${entry.file} has no reason`).toBeGreaterThan(20)
}
})
})
// A census that scans only trees which happen to hold no .web.* file passes for the wrong reason.
// These plant one in each scanned tree and show the first assertion above would report it.
describe('the census scan', () => {
it('reports an unlisted .web.* in every tree it claims to cover', async () => {
const planted = {
src: 'src/transport/planted.web.ts',
app: 'app/h/[hostId]/edit.web.tsx',
'web-entry': 'web-entry/planted.web.tsx',
packages: 'packages/expo-two-way-audio/src/Planted.web.ts'
}
for (const [tree, file] of Object.entries(planted)) {
await withScratch(async (scratch) => {
await plant(scratch, file)
expect(
await findWebSiblings(scratch),
`${tree} is scanned but ${file} went unseen`
).toEqual([file])
})
}
})
it('skips node_modules, which vendors thousands of unrelated .web.js files', async () => {
await withScratch(async (scratch) => {
await plant(scratch, 'packages/x/node_modules/dep/index.web.js')
expect(await findWebSiblings(scratch)).toEqual([])
})
})
})
@@ -142,6 +142,15 @@ describe('mobile web bundle packaging coverage', () => {
expect(job.text).toMatch(BUNDLE_PRODUCER)
}
)
it.each(packagingJobs().map((job) => [job.label, job]))(
'installs mobile/node_modules before electron-builder packs: %s',
(_label, job) => {
// mobile is a separate pnpm project, so the root install leaves it empty and the bundle
// build cannot resolve React Native or Expo. One definition, so no job hand-rolls it.
expect(job.text).toContain('uses: ./.github/actions/install-mobile-dependencies')
}
)
})
describe('the build scripts the census trusts', () => {
@@ -111,7 +111,9 @@ describe('the three mobile web bundle serializers', () => {
runtimeProtocolVersion: 2,
entrypoint: 'index.html',
totalBytes: ASSETS.reduce((total, asset) => total + asset.byteLength, 0),
assets: [...ASSETS]
assets: [...ASSETS],
// Outside the hash on purpose, which the assertion below is what says.
routes: [{ pathname: '/h/[hostId]', grants: ['navigate'] }]
}
expect(MobileWebBundleManifestSchema.parse(manifest).buildId).toBe(buildId)
+20
View File
@@ -0,0 +1,20 @@
/**
* The screens this desktop asks a phone's shell to render from the app bundle instead of natively.
*
* One entry per route proved on the web, and the list is deliberately short: a route that is not
* here renders the native screen, which is the state every phone is already in. Adding one is a
* product decision with a device proof behind it, not a consequence of the bundle happening to
* contain the module.
*
* `grants` names what the screen needs the shell to do for it. A shell that implements fewer than
* an entry names renders the native screen for that route, so writing a grant here before the app
* that implements it ships costs nothing and breaks nothing.
*
* Declared here rather than in src/shared because the builder is the only thing that reads it: the
* shape it must satisfy is MobileWebBundleRouteSchema, which the manifest write is checked against.
*/
export const MOBILE_WEB_PAGE_ROUTES = [
// The worktree list. `navigate` because every row opens a session screen that is still native.
// `storage` because its pins and its last-visited repo are the app's, not the document's.
{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] }
]
+30
View File
@@ -26,6 +26,7 @@ export const PR_CHECK_JOBS = [
'shell_contracts',
'test',
'orcad_browser',
'mobile_web_app',
'cross-version-wire',
'managed_hook_node18',
'package',
@@ -106,6 +107,27 @@ const ORCAD_BROWSER_PREFIXES = [
'src/main/orcad/electron-serve-browser-process'
]
// The Route A page bundle: the builder and verifier, the entry, the route tree it mounts, the
// mobile source those routes import, and the shell policy the render check runs the page under.
const MOBILE_WEB_APP_PREFIXES = [
'config/scripts/build-mobile-web-app',
'config/scripts/verify-mobile-web-app-bundle',
'config/scripts/mobile-web-app-',
'config/scripts/build-mobile-web-bundle',
'config/scripts/verify-mobile-web-bundle',
'mobile/web-entry/',
'mobile/app/',
'mobile/src/',
'mobile/packages/',
'mobile/package.json',
'mobile/pnpm-lock.yaml',
'mobile/modules/orca-mobile-web-shell/'
]
function changesMobileWebApp(changedFiles) {
return changedFiles.some((file) => matchesPrefix(file, MOBILE_WEB_APP_PREFIXES))
}
const CROSS_VERSION_WIRE_PREFIXES = [
'tests/e2e/cross-version-wire/',
'src/shared/protocol-version',
@@ -358,6 +380,10 @@ export function classifyPrJobs(changedFiles) {
// but the repo-wide audits lint mobile/, and skipping them lands the violation on main, where
// it then fails this same gate on every later PR's merge ref.
jobs.static_analysis = jobs.static_analysis || changedFiles.some(isStaticAnalysisScannedPath)
// Why outside should_run, for the same reason: a mobile-only diff is desktop-irrelevant, and
// that is exactly the diff that changes the page this job builds. Gated on should_run it would
// skip on every PR that can break it and run on none.
jobs.mobile_web_app = jobs.mobile_web_app || changesMobileWebApp(changedFiles)
return {
should_run: shouldRun,
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
@@ -380,6 +406,10 @@ function jobDetector(job) {
return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
case 'orcad_browser':
return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
// Not redundant with the lift below the jobs map: without a case here the default detector
// returns true, which would run this job on every desktop-relevant PR.
case 'mobile_web_app':
return changesMobileWebApp
case 'cross-version-wire':
return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
case 'managed_hook_node18':
+47 -3
View File
@@ -255,6 +255,39 @@ describe('per-job path classification', () => {
})
})
it('runs the mobile web app job for the builder, the page source and the shell policy', () => {
for (const file of [
'config/scripts/build-mobile-web-app-bundle.mjs',
'config/scripts/mobile-web-app-route-manifest.mjs',
'mobile/web-entry/index.tsx',
'mobile/app/h/[hostId]/index.tsx',
'mobile/src/transport/client-context.web.tsx',
'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift',
// The vendored Expo module the page resolves a .web.ts out of.
'mobile/packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts'
]) {
expect(classifyPrJobs([file]).mobile_web_app, file).toBe(true)
}
})
it('runs it on a mobile-only diff, which should_run alone would skip', () => {
const classified = classifyPrJobs(['mobile/app/h/[hostId]/tasks.tsx'])
expect(classified.should_run).toBe(false)
expect(classified.mobile_web_app).toBe(true)
})
it('needs no package.json prefix, because package.json already forces every job', () => {
// build:mobile-web:app is defined there, so the job has to run on an edit to it. A prefix
// that broad is not how: GLOBAL_FORCE_FILES already covers the file.
expect(classifyPrJobs(['package.json']).mobile_web_app).toBe(true)
})
it('leaves it off for changes that cannot reach the page', () => {
for (const file of ['docs/reference/x.md', 'src/main/orcad/orcad-native-preflight.ts']) {
expect(classifyPrJobs([file]).mobile_web_app, file).toBe(false)
}
})
it('runs cross-version wire checks for every working-tree wire module', () => {
for (const file of [
'src/shared/protocol-version.ts',
@@ -456,13 +489,24 @@ describe('PR Checks skip wiring', () => {
'${{ steps.filter.outputs.mobile_dependencies }}'
)
const steps = prWorkflow.jobs.static_analysis.steps
const install = steps.findIndex((step) => step.name === 'Install mobile dependencies')
const install = steps.findIndex(
(step) => step.uses === './.github/actions/install-mobile-dependencies'
)
const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
expect(install).toBeGreaterThan(-1)
expect(install).toBeLessThan(gate)
expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'")
expect(steps[install]['working-directory']).toBe('mobile')
expect(steps[install].run).toContain('--frozen-lockfile')
// The install itself moved into the action the packaging jobs share; assert it there so
// this job cannot keep the step while the action stops installing anything.
const action = parse(
readFileSync(
join(projectDir, '.github/actions/install-mobile-dependencies/action.yml'),
'utf8'
)
)
const [installStep] = action.runs.steps
expect(installStep['working-directory']).toBe('mobile')
expect(installStep.run).toContain('--frozen-lockfile')
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
@@ -1,6 +1,7 @@
import { existsSync, globSync, readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundle-dependencies.mjs'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
@@ -463,6 +464,7 @@ describe('PR workflow parallelism', () => {
'shell_contracts',
'test',
'orcad_browser',
'mobile_web_app',
'cross-version-wire',
'managed_hook_node18',
'package',
@@ -479,5 +481,19 @@ describe('PR workflow parallelism', () => {
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
// Same reason as the browser provider: the render check fails loudly on a runner with no
// Chrome, which only guards the page if verify reads the job's result.
expect(verifyStep.env.MOBILE_WEB_APP).toBe('${{ needs.mobile_web_app.result }}')
expect(verifyStep.run).toContain('"$MOBILE_WEB_APP"')
})
it('makes the mobile_web_app job refuse to skip the tests it exists to run', () => {
// The bundling tests skip themselves without mobile/node_modules, which is what keeps the
// sharded `test` job green. Only this env var stops that skip from spreading to the one job
// that installs them, so a typo here would leave the whole job passing vacuously.
const step = workflow.jobs.mobile_web_app.steps.find((entry) =>
entry.run?.includes('build-mobile-web-app-bundle.test.mjs')
)
expect(step.env[MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV]).toBe('1')
})
})
+4
View File
@@ -37,6 +37,10 @@ export function readRendererBootGraph(rendererDir) {
export function bootGraphForbiddenPayloads(root = process.cwd()) {
return [
{ label: '@xterm/addon-webgl', signature: 'WebGL2 not supported' },
{
label: '@xterm/addon-image',
signature: 'invalid storageLimit, should be at least 0.5 MB and not exceed 1G'
},
{ label: 'i18n/locales/en.json', signature: prunedAwayEnglishSignature(root) }
// Not zod: six other shared modules on the boot path (runtime environments,
// closed-tab tombstones, the browser page protocol, shared/constants…)
+1
View File
@@ -80,6 +80,7 @@ const result = spawnSync(
'tests/e2e/ssh-skill-installation.spec.ts',
'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
'tests/e2e/terminal-inline-images-ssh.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
@@ -0,0 +1,203 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as esbuild from 'esbuild'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { isDirectInvocation } from './build-mobile-web-bundle.mjs'
import { assertNoCarriageReturnsInSource } from './verify-mobile-web-bundle.mjs'
import { assertMobileWebBundleBuilt } from './verify-packaged-mobile-web-bundle.cjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const defaultBundleDir = join(projectDir, 'out', 'mobile-web-app')
const manifestContract = join(
projectDir,
'src',
'shared',
'mobile-web-bundle',
'manifest-contract.ts'
)
/**
* The document, the route chunks and the images the route tree imports. Derived rather than
* pinned, because a flat number stops agreeing with the chunk ceiling as routes are added: at 128
* and 42 images, 18 routes are already allowed 88 chunks, and 88 + 42 + 1 is 131, so the asset
* count would have failed first and named the count rather than the split that caused it. Written
* as chunks + images + the document, a bundle at the chunk ceiling sits exactly at this one, so
* the chunk ceiling always trips first and the failure says what actually grew.
*/
export function mobileWebAppBundleMaxAssets(routeCount, imageCount) {
return mobileWebAppBundleMaxChunks(routeCount) + imageCount + 1
}
/**
* Phase C byte budget for the app bundle, not the contract ceiling (10 MiB per asset,
* MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES). Deliberately below it so growth trips a build rather than a
* refused asset on a phone. Splitting barely moves it — the same code is emitted in more files —
* so shrinking this still means cutting code.
*/
export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
/**
* How many scripts the page may be cut into, for a given number of routes. A chunk is emitted per
* distinct set of importers rather than per route, so the count is combinatorial in what the
* routes share: 8 routes measure 23 chunks, 10 measure 40, 12 measure 47, 14 measure 53, about
* three more per route at the top. Four per route with a flat 16 leaves the next few routes room,
* so a route added in C2 fails on its own weight and not on a number measured before it existed.
*
* This is the ceiling that catches a split running away; MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES
* below is the one that catches it collapsing, and it is the real budget of the two.
*/
export function mobileWebAppBundleMaxChunks(routeCount) {
return 4 * routeCount + 16
}
/**
* What the browser must parse before the first route can paint: the entry plus every chunk it
* reaches by static import. This is the budget splitting exists to hold — it was 8.16 MB as one
* chunk and measures 0.89 MiB split — so a route re-imported statically, or `splitting` dropped,
* fails the build here instead of arriving as a slow first open on a phone.
*
* It is not a per-route escape hatch. Importing one route statically already breaks this bound
* for 5 of the 14: session at 7.16 MiB, tasks 5.91, source-control 5.78, review 5.77,
* files/preview 5.21. What keeps the hatch usable at all is that expo-router reads
* `unstable_settings` off layout nodes only, and the subtree's one layout, `h/_layout.tsx`,
* measures 2.22 MiB static. Any other route needing a synchronous export needs this number
* re-measured, not a static import.
*/
export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES = 3 * 1024 * 1024
/** Every tree whose bytes reach the buildId, so a CRLF checkout cannot fork it. */
export const MOBILE_WEB_APP_SOURCE_DIRS = [
join(projectDir, 'mobile', 'web-entry'),
join(projectDir, 'mobile', 'app'),
join(projectDir, 'mobile', 'src')
]
class VerificationError extends Error {}
function fail(message) {
throw new VerificationError(message)
}
/**
* How many assets the phone will accept, read from the contract rather than copied: the native
* shells hold their own 256 and refuse a larger manifest outright. Bundled through esbuild
* because node cannot resolve that module's extensionless TypeScript imports, so the number is
* evaluated from the contract and not parsed out of it.
*/
export async function readMobileWebBundleMaxAssets() {
const { outputFiles } = await esbuild.build({
entryPoints: [manifestContract],
bundle: true,
write: false,
format: 'esm',
platform: 'node',
logLevel: 'silent'
})
const source = Buffer.from(outputFiles[0].contents).toString('base64')
const { MOBILE_WEB_BUNDLE_MAX_ASSETS: ceiling } = await import(
`data:text/javascript;base64,${source}`
)
if (typeof ceiling !== 'number') {
fail(`${manifestContract} exports no MOBILE_WEB_BUNDLE_MAX_ASSETS to bound the build with`)
}
return ceiling
}
/**
* The derived ceiling is only a budget while it stays inside the map the phone can hold: the
* shells return null for a manifest over MOBILE_WEB_BUNDLE_MAX_ASSETS rather than dropping the
* extra assets, so a route count that pushes 4r + 16 + images + 1 past it would pass this build
* and fail on the device with nothing to read. At today's 42 images that is 50 routes, inside
* what Phase C adds, which is why this is a build failure and not a comment.
*/
export function assertAssetCeilingFitsShell(routeCount, imageCount, shellMaxAssets) {
const ceiling = mobileWebAppBundleMaxAssets(routeCount, imageCount)
if (ceiling > shellMaxAssets) {
fail(
`the ceiling derived for ${String(routeCount)} route(s) and ${String(imageCount)} image(s) ` +
`is ${String(ceiling)} assets, over the ${String(shellMaxAssets)} the shell will load`
)
}
return ceiling
}
async function buildIntoScratch() {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-verify-'))
try {
return await buildMobileWebAppBundle({ outDir: join(scratch, 'mobile-web-app') })
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
// bundleDir is a seam for the tests, which verify a scratch build; the script always verifies out/.
export async function verifyMobileWebAppBundle({ bundleDir = defaultBundleDir } = {}) {
for (const directory of MOBILE_WEB_APP_SOURCE_DIRS) {
await assertNoCarriageReturnsInSource(directory)
}
const manifest = assertMobileWebBundleBuilt(bundleDir)
if (manifest.totalBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
fail(
`bundle is ${String(manifest.totalBytes)} bytes, over the Phase C budget of ` +
`${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)}`
)
}
const first = await buildIntoScratch()
const second = await buildIntoScratch()
if (first.manifest.buildId !== second.manifest.buildId) {
fail(`buildId is not reproducible: ${first.manifest.buildId} then ${second.manifest.buildId}`)
}
if (first.manifest.buildId !== manifest.buildId) {
fail(
`${bundleDir} is stale: it carries buildId ${manifest.buildId}, a fresh build produces ${first.manifest.buildId}`
)
}
// Read off the fresh build rather than the manifest: neither bound is a manifest field, and the
// buildId just proved this build is the one on disk.
// After the fresh build, which is what knows how many of the assets are images.
const maxAssets = assertAssetCeilingFitsShell(
first.routeKeys.length,
first.imageCount,
await readMobileWebBundleMaxAssets()
)
if (manifest.assets.length > maxAssets) {
fail(
`bundle has ${String(manifest.assets.length)} assets, over the Phase C budget of ` +
`${String(maxAssets)} for ${String(first.routeKeys.length)} route(s) and ` +
`${String(first.imageCount)} image(s)`
)
}
const maxChunks = mobileWebAppBundleMaxChunks(first.routeKeys.length)
if (first.chunkCount > maxChunks) {
fail(
`bundle is cut into ${String(first.chunkCount)} chunks, over the Phase C budget of ` +
`${String(maxChunks)} for ${String(first.routeKeys.length)} route(s)`
)
}
if (first.entryStaticBytes > MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES) {
fail(
`${String(first.entryStaticBytes)} bytes load before the first route, over the Phase C ` +
`budget of ${String(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES)}`
)
}
return manifest
}
if (isDirectInvocation(import.meta.url, process.argv[1])) {
try {
const manifest = await verifyMobileWebAppBundle()
console.log(
`[verify-mobile-web-app-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
`${String(manifest.totalBytes)}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes, ` +
`reproducible buildId ${manifest.buildId}`
)
} catch (error) {
console.error(`[verify-mobile-web-app-bundle] ${error.message}`)
process.exit(1)
}
}
+24 -3
View File
@@ -44,6 +44,24 @@ async function listSourceFiles(directory) {
return files.sort()
}
/**
* Pinned `-text` in .gitattributes and skipped below, because a 0x0d in them means nothing. .svg
* is absent on purpose: it is text, so the eol=lf pin applies and a CRLF .svg forks the buildId.
* A test keeps this list and the .gitattributes exemptions in step.
*/
export const BINARY_SOURCE_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.ico',
'.webp',
'.ttf',
'.otf',
'.woff',
'.woff2'
]
/**
* A CRLF checkout changes the bytes of every text source, which changes every asset hash and so
* the buildId. .gitattributes pins eol=lf; this is what notices when that pin stops working.
@@ -51,8 +69,11 @@ async function listSourceFiles(directory) {
export async function assertNoCarriageReturnsInSource(directory = sourceDir) {
const offenders = []
for (const file of await listSourceFiles(directory)) {
// Binary assets are pinned -text and may legitimately contain 0x0d.
if (file.endsWith('.png')) {
if (BINARY_SOURCE_EXTENSIONS.some((extension) => file.endsWith(extension))) {
continue
}
// Written by mobile's postinstall, gitignored, so no eol pin applies and none is needed.
if (file.endsWith('.generated.ts')) {
continue
}
if ((await readFile(file)).includes(0x0d)) {
@@ -62,7 +83,7 @@ export async function assertNoCarriageReturnsInSource(directory = sourceDir) {
if (offenders.length > 0) {
fail(
`CRLF in mobile web source, which would change every asset hash and the buildId: ` +
`${offenders.join(', ')}. Check the .gitattributes eol=lf pin for src/mobile-web.`
`${offenders.join(', ')}. Check the .gitattributes eol=lf pin for ${directory}.`
)
}
}
@@ -0,0 +1,229 @@
import { createRequire } from 'node:module'
import { crc32, deflateRawSync, deflateSync } from 'node:zlib'
import { afterEach, describe, expect, it, vi } from 'vitest'
const require = createRequire(import.meta.url)
const { Terminal } = require('@xterm/xterm')
const { ImageAddon } = require('@xterm/addon-image')
class TrackedBitmap {
width = 1
height = 1
close = vi.fn()
}
function createTerminal() {
vi.stubGlobal('ImageBitmap', TrackedBitmap)
vi.stubGlobal('window', { ImageBitmap: TrackedBitmap })
const terminal = new Terminal({ allowProposedApi: true })
const addon = new ImageAddon({
enableSizeReports: false,
storageLimit: 32,
kittySizeLimit: 8 * 1024 * 1024
})
terminal.loadAddon(addon)
return { terminal, addon, storage: addon._storage, kitty: addon._handlers.get('kitty') }
}
afterEach(() => vi.unstubAllGlobals())
describe('xterm image allocation lifecycle', () => {
it('closes alternate-buffer bitmaps when images are reset', async () => {
const { terminal, addon, storage } = createTerminal()
try {
await new Promise((resolve) => terminal.write('\x1b[?1049h', resolve))
const bitmap = new TrackedBitmap()
storage.addImage(bitmap, { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' })
expect(storage._images.size).toBe(1)
addon.reset()
expect(storage._images.size).toBe(0)
expect(bitmap.close).toHaveBeenCalledOnce()
} finally {
terminal.dispose()
}
})
it.each(['reset', 'dispose'])(
'discards IIP decode that finishes after addon %s',
async (action) => {
const { terminal, addon, storage } = createTerminal()
try {
let finishDecode
vi.stubGlobal(
'createImageBitmap',
() =>
new Promise((resolve) => {
finishDecode = resolve
})
)
const iip = addon._handlers.get('iip')
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
const payload = Uint32Array.from(`File=inline=1:${png}`, (char) => char.codePointAt(0))
iip.start()
iip.put(payload, 0, payload.length)
const decoding = iip.end(true)
expect(finishDecode).toBeTypeOf('function')
addon[action]()
const bitmap = new TrackedBitmap()
finishDecode(bitmap)
await decoding
expect(storage._images.size).toBe(0)
expect(bitmap.close).toHaveBeenCalledOnce()
} finally {
terminal.dispose()
}
}
)
it.each(['hide', 'dispose'])('closes placeholder bitmap finishing after %s', async (action) => {
const { terminal, addon } = createTerminal()
try {
const renderer = addon._renderer
const context = {
createImageData: (width, height) => ({ data: new Uint8ClampedArray(width * height * 4) }),
putImageData: () => {},
drawImage: () => {}
}
vi.stubGlobal('document', { createElement: () => ({ getContext: () => context }) })
vi.stubGlobal('screen', { width: 800 })
let finishDecode
vi.stubGlobal(
'createImageBitmap',
() =>
new Promise((resolve) => {
finishDecode = resolve
})
)
renderer._createPlaceHolder(24)
if (action === 'dispose') {
addon.dispose()
} else {
renderer.showPlaceholder(false)
}
const bitmap = new TrackedBitmap()
finishDecode(bitmap)
await Promise.resolve()
expect(bitmap.close).toHaveBeenCalledOnce()
expect(renderer._placeholderBitmap).toBeUndefined()
} finally {
terminal.dispose()
}
})
it('rejects oversized PNG headers before native bitmap allocation', async () => {
const { terminal, kitty } = createTerminal()
try {
const decode = vi.fn(async () => new TrackedBitmap())
vi.stubGlobal('createImageBitmap', decode)
window.createImageBitmap = decode
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==',
'base64'
)
png.writeUInt32BE(100_000, 16)
png.writeUInt32BE(100_000, 20)
png.writeUInt32BE(crc32(png.subarray(12, 29)), 29)
const result = await kitty._createBitmap({ format: 100, data: new Blob([png]) }).then(
() => 'accepted',
() => 'rejected'
)
expect(decode).not.toHaveBeenCalled()
expect(result).toBe('rejected')
} finally {
terminal.dispose()
}
})
it.each([deflateSync, deflateRawSync])(
'preserves valid compressed image bytes (%#)',
async (compress) => {
const { terminal, kitty } = createTerminal()
try {
const original = Buffer.from([1, 2, 3, 4])
expect(Buffer.from(await kitty._decompressZlib(compress(original)))).toEqual(original)
} finally {
terminal.dispose()
}
}
)
it('contains malformed compressed streams without unhandled writer rejections', async () => {
const { terminal, kitty } = createTerminal()
try {
await expect(kitty._decompressZlib(new Uint8Array([1, 2, 3, 4]))).rejects.toThrow()
} finally {
terminal.dispose()
}
})
it('rejects compressed data expanding beyond the decoded-image budget', async () => {
const { terminal, kitty } = createTerminal()
try {
const compressed = deflateSync(Buffer.alloc(64 * 1024 * 1024))
expect(compressed.byteLength).toBeLessThan(100_000)
const result = await kitty._decompressZlib(compressed).then(
(bytes) => ({ decodedBytes: bytes.byteLength }),
() => ({ rejected: true })
)
expect(result).toEqual({ rejected: true })
} finally {
terminal.dispose()
}
})
it.each([{ x: 1 }, { columns: 2 }])(
'discards Kitty image reset during crop or resize (%#)',
async (command) => {
const { terminal, addon, storage, kitty } = createTerminal()
try {
const original = new TrackedBitmap()
original.width = original.height = 10
kitty._createBitmap = async () => original
let finishTransform
vi.stubGlobal(
'createImageBitmap',
() =>
new Promise((resolve) => {
finishTransform = resolve
})
)
const decoding = kitty._displayImage({ id: 1 }, command)
await Promise.resolve()
expect(finishTransform).toBeTypeOf('function')
addon.reset()
const transformed = new TrackedBitmap()
finishTransform(transformed)
await decoding
expect(storage._images.size).toBe(0)
expect(original.close).toHaveBeenCalledOnce()
expect(transformed.close).toHaveBeenCalledOnce()
} finally {
terminal.dispose()
}
}
)
it.each(['reset', 'dispose'])(
'discards Kitty decode that finishes after addon %s',
async (action) => {
const { terminal, addon, storage, kitty } = createTerminal()
try {
let finishDecode
kitty._createBitmap = () =>
new Promise((resolve) => {
finishDecode = resolve
})
const decoding = kitty._displayImage({ id: 1 }, {})
addon[action]()
const bitmap = new TrackedBitmap()
finishDecode(bitmap)
await decoding
expect(storage._images.size).toBe(0)
expect(bitmap.close).toHaveBeenCalledOnce()
} finally {
terminal.dispose()
}
}
)
})
@@ -0,0 +1,129 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { Terminal } = require('@xterm/xterm')
const { ImageAddon } = require('@xterm/addon-image')
function createTerminal(options = {}) {
const terminal = new Terminal({ allowProposedApi: true })
const addon = new ImageAddon({
enableSizeReports: false,
storageLimit: 32,
kittySizeLimit: 8 * 1024 * 1024,
...options
})
terminal.loadAddon(addon)
const handler = addon._handlers.get('kitty')
if (options.kittyStorageLimit !== undefined) {
handler._kittyStorage._storage.setLimit(options.kittyStorageLimit)
}
return { terminal, addon, handler }
}
function writeKitty(terminal, command, payload) {
return new Promise((resolve) => terminal.write(`\x1b_G${command};${payload}\x1b\\`, resolve))
}
describe('xterm image memory contract', () => {
it('does not emit a reply when evicting an id-less upload', async () => {
const { terminal, handler } = createTerminal()
const replies = []
terminal.onData((data) => replies.push(data))
try {
await writeKitty(terminal, 'a=t,f=32,s=1,v=1,m=1', 'AAAA')
await writeKitty(terminal, 'a=t,f=32,s=1,v=1,i=1,m=1,q=2', 'AAAA')
await writeKitty(terminal, 'a=t,f=32,s=1,v=1,i=2,m=1,q=2', 'AAAA')
expect(handler._pendingTransmissions.has(0)).toBe(false)
expect(handler._pendingTransmissions.size).toBe(2)
expect(replies).toEqual([])
} finally {
terminal.dispose()
}
})
it('bounds abandoned uploads by retained decoder capacity and accepts a continuation', async () => {
const { terminal, handler } = createTerminal()
try {
for (let id = 1; id <= 40; id++) {
await writeKitty(terminal, `a=t,f=32,s=1,v=1,i=${id},m=1,q=2`, 'AAAA')
const pending = [...handler._pendingTransmissions.values()]
const retainedBytes = pending.reduce(
(total, upload) => total + upload.decoder._mem.buffer.byteLength,
0
)
expect(retainedBytes).toBeLessThanOrEqual(32_000_000)
expect(pending.length).toBeLessThanOrEqual(2)
}
await writeKitty(terminal, 'm=0,q=2', 'AA==')
expect(handler._kittyStorage.getImage(40).data.size).toBe(4)
terminal.dispose()
expect(handler._pendingTransmissions.size).toBe(0)
} finally {
terminal.dispose()
}
})
it('rejects a decoder that cannot fit the storage budget before allocation', async () => {
const { terminal, handler } = createTerminal({ storageLimit: 8 })
try {
await writeKitty(terminal, 'a=t,f=32,s=1,v=1,i=9,m=1,q=2', 'AAAA')
expect(handler._pendingTransmissions.size).toBe(0)
expect(handler._aborted).toBe(true)
} finally {
terminal.dispose()
}
})
it('evicts transmitted images by byte size before placement', async () => {
const { terminal, handler } = createTerminal({ storageLimit: 12, kittyStorageLimit: 0.5 })
const payload = Buffer.alloc(200_000, 1).toString('base64')
try {
for (let id = 1; id <= 4; id++) {
await writeKitty(terminal, `a=t,f=32,s=250,v=200,i=${id},q=2`, payload)
const retainedBytes = [...handler._kittyStorage.images.values()].reduce(
(total, image) => total + image.data.size,
0
)
expect(retainedBytes).toBeLessThanOrEqual(500_000)
}
expect(handler._kittyStorage.getImage(1)).toBeUndefined()
expect(handler._kittyStorage.getImage(3).data.size).toBe(200_000)
expect(handler._kittyStorage.getImage(4).data.size).toBe(200_000)
await writeKitty(terminal, 'a=d,d=A,q=2', '')
expect(handler._kittyStorage.images.size).toBe(0)
} finally {
terminal.dispose()
}
})
it('evicts unplaced payloads before displayed ones', async () => {
const { terminal, handler } = createTerminal({ storageLimit: 12, kittyStorageLimit: 0.5 })
const storage = handler._kittyStorage
const payload = Buffer.alloc(200_000, 1).toString('base64')
try {
await writeKitty(terminal, 'a=t,f=32,s=250,v=200,i=1,q=2', payload)
await writeKitty(terminal, 'a=t,f=32,s=250,v=200,i=2,q=2', payload)
// Placement bookkeeping only; a real addImage needs a canvas this env lacks.
storage._kittyIdToStorageId.set(1, 1001)
storage._storageIdToKittyId.set(1001, 1)
await writeKitty(terminal, 'a=t,f=32,s=250,v=200,i=3,q=2', payload)
expect(storage.getImage(1)).toBeDefined()
expect(storage.getImage(2)).toBeUndefined()
expect(storage.getImage(3).data.size).toBe(200_000)
} finally {
terminal.dispose()
}
})
it('stores an image larger than the byte budget rather than acking a dropped one', async () => {
const { terminal, handler } = createTerminal({ storageLimit: 12, kittyStorageLimit: 0.5 })
const payload = Buffer.alloc(600_000, 1).toString('base64')
try {
await writeKitty(terminal, 'a=t,f=32,s=500,v=300,i=1,q=2', payload)
expect(handler._kittyStorage.getImage(1).data.size).toBe(600_000)
} finally {
terminal.dispose()
}
})
})
@@ -0,0 +1,38 @@
import { createRequire } from 'node:module'
import { afterEach, expect, it, vi } from 'vitest'
const require = createRequire(import.meta.url)
const { Terminal } = require('@xterm/xterm')
const { ImageAddon } = require('@xterm/addon-image')
afterEach(() => vi.unstubAllGlobals())
it('scales visible tiles without allocating a full enlarged image on font zoom', () => {
const terminal = new Terminal({ allowProposedApi: true })
const addon = new ImageAddon({ enableSizeReports: false, storageLimit: 32 })
terminal.loadAddon(addon)
const createCanvas = vi.fn(() => ({ getContext: () => ({ drawImage: vi.fn() }) }))
vi.stubGlobal('document', { createElement: createCanvas })
const renderer = addon._renderer
vi.spyOn(renderer, 'cellSize', 'get').mockReturnValue({ width: 90, height: 90 })
const drawImage = vi.fn()
renderer._layers.set('top', { drawImage, clearRect() {}, canvas: { remove() {} } })
const original = { width: 2000, height: 2000 }
const spec = {
orig: original,
actual: original,
origCellSize: { width: 10, height: 10 },
actualCellSize: { width: 10, height: 10 },
layer: 'top'
}
try {
renderer.draw(spec, 201, 2, 3)
expect(createCanvas).not.toHaveBeenCalled()
expect(drawImage).toHaveBeenCalledWith(original, 10, 10, 10, 10, 180, 270, 90, 90)
const tile = renderer.extractTile(spec, 201)
expect(tile.width).toBe(90)
expect(tile.height).toBe(90)
} finally {
terminal.dispose()
}
})
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 62m">
<title>downloads: 62m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 67m">
<title>downloads: 67m</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">62m</text>
<text x="90" y="14">62m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">67m</text>
<text x="90" y="14">67m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -0,0 +1,28 @@
# Local log-tail watchers outliving their renderer
Main can receive a log-tail subscription, await path authorization, and finish installing its native watcher after the requesting renderer has gone away. Previously, the destroyed listener was registered only after authorization. Installed watchers also survived a renderer crash or a new document loaded into the same WebContents. The map retained each watcher and its sender callback; callbacks suppressed notifications to destroyed senders without releasing resources. This handler is present in `v1.4.198`.
The fix gives each sender one owner using the existing `abortWhenRendererGone` policy: destruction, renderer process loss, or committed document navigation closes its live watches and invalidates pending authorization. Same-document and canceled navigation preserve the owner. For a reused subscription ID, the latest pending request wins. Each pending subscription has an identity token; old completions and old watcher errors cannot replace or close newer subscriptions. Failed authorization preserves an existing installed watch. The last pending/live release removes all owner listeners.
This is a reproduced native-handle and small metadata leak. Watchers do not retain file-content chunks. It does not establish the input frequency or memory scale in [#19768](https://github.com/stablyai/orca/issues/19768) or [#19831](https://github.com/stablyai/orca/issues/19831).
## Reproduce
From the repository root with existing dependencies:
```sh
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/local-log-tail-lifetime/reproduce.mjs
```
The script runs the actual IPC handlers against temporary files, real `fs.watch` handles, controlled authorization promises, and EventEmitter senders. The existing IPC tests use watcher doubles to deliver an error from a retired watcher. No Electron window, real user log, process inventory, or network request is used. Test cleanup releases all watchers.
The baseline reverses only `fix.patch` in a temporary Vite transform. Working sources remain unchanged; source hashes and exact failed cases are recorded in `results.json`. Child test runners use the shared cross-platform process runner.
| Version | Passed | Failed |
| ------------------- | -----: | -----: |
| Before lifetime fix | 9 | 10 |
| With lifetime fix | 19 | 0 |
The twenty-owner case retained twenty native watcher owners before the fix and zero afterward. The broader cases cover destruction during authorization, active-plus-pending replacement, process loss/navigation, failed replacement, explicit stop, idle listener disposal, superseded success/error, and failed native installation. Ordinary tab cancellation already waited for start before stop; that behavior remains covered by the renderer hook tests.
Additional validation: Node typecheck, direct lint, and the existing renderer-lifetime and local-log-tail hook suites. This endpoint only watches renderer-authorized local logs. SSH/paired-runtime execution ownership and wire schemas do not change; the local editor eligibility check already excludes runtime-environment files. Folder workspaces follow the existing path authorization policy.
@@ -0,0 +1,210 @@
diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts
index 430882b4e0..0892665ad5 100644
--- a/src/main/ipc/local-log-tail.ts
+++ b/src/main/ipc/local-log-tail.ts
@@ -9,35 +9,83 @@ import type {
} from '../../shared/local-log-tail-types'
import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader'
import { resolveAuthorizedPath } from './filesystem-auth'
+import { abortWhenRendererGone } from './renderer-lifetime-abort'
-type TailWatch = {
+type TailSenderOwner = {
senderId: number
+ pending: Map<string, symbol>
+ watchKeys: Set<string>
+ signal: AbortSignal
+ dispose: () => void
+}
+
+type TailWatch = {
+ owner: TailSenderOwner
watcher: FSWatcher
}
const tailWatches = new Map<string, TailWatch>()
-const senderCleanupRegistered = new Set<number>()
+const senderOwners = new Map<number, TailSenderOwner>()
function watchKey(senderId: number, subscriptionId: string): string {
return `${senderId}:${subscriptionId}`
}
-function closeWatch(key: string): void {
+function releaseIdleOwner(owner: TailSenderOwner): void {
+ if (owner.pending.size > 0 || owner.watchKeys.size > 0) {
+ return
+ }
+ if (senderOwners.get(owner.senderId) === owner) {
+ senderOwners.delete(owner.senderId)
+ }
+ owner.dispose()
+}
+
+function closeWatch(key: string, expected?: TailWatch): void {
const subscription = tailWatches.get(key)
- if (!subscription) {
+ if (!subscription || (expected && subscription !== expected)) {
return
}
tailWatches.delete(key)
- subscription.watcher.close()
+ subscription.owner.watchKeys.delete(key)
+ try {
+ subscription.watcher.close()
+ } finally {
+ releaseIdleOwner(subscription.owner)
+ }
}
-function closeSenderWatches(senderId: number): void {
- senderCleanupRegistered.delete(senderId)
- for (const [key, subscription] of tailWatches) {
- if (subscription.senderId === senderId) {
- closeWatch(key)
+function closeSenderWatches(owner: TailSenderOwner): void {
+ owner.pending.clear()
+ for (const key of owner.watchKeys) {
+ const subscription = tailWatches.get(key)
+ if (subscription?.owner === owner) {
+ closeWatch(key, subscription)
+ }
+ }
+ releaseIdleOwner(owner)
+}
+
+function getSenderOwner(sender: WebContents): TailSenderOwner {
+ const existing = senderOwners.get(sender.id)
+ if (existing) {
+ return existing
+ }
+ const lifetime = abortWhenRendererGone(sender)
+ const onAbort = (): void => closeSenderWatches(owner)
+ const owner: TailSenderOwner = {
+ senderId: sender.id,
+ pending: new Map(),
+ watchKeys: new Set(),
+ signal: lifetime.signal,
+ dispose: () => {
+ lifetime.signal.removeEventListener('abort', onAbort)
+ lifetime.dispose()
}
}
+ senderOwners.set(sender.id, owner)
+ lifetime.signal.addEventListener('abort', onAbort, { once: true })
+ return owner
}
function validateSubscriptionId(value: unknown): string {
@@ -47,12 +95,52 @@ function validateSubscriptionId(value: unknown): string {
return value
}
-function registerSenderCleanup(sender: WebContents): void {
- if (senderCleanupRegistered.has(sender.id)) {
+async function startWatch(
+ sender: WebContents,
+ args: LocalLogTailWatchArgs,
+ store: Store
+): Promise<void> {
+ const subscriptionId = validateSubscriptionId(args.subscriptionId)
+ if (sender.isDestroyed()) {
return
}
- senderCleanupRegistered.add(sender.id)
- sender.once('destroyed', () => closeSenderWatches(sender.id))
+ const key = watchKey(sender.id, subscriptionId)
+ const owner = getSenderOwner(sender)
+ const pending = Symbol(subscriptionId)
+ owner.pending.set(key, pending)
+ try {
+ const filePath = await resolveAuthorizedPath(args.filePath, store)
+ if (
+ sender.isDestroyed() ||
+ owner.signal.aborted ||
+ senderOwners.get(sender.id) !== owner ||
+ owner.pending.get(key) !== pending
+ ) {
+ return
+ }
+ closeWatch(key)
+ const sendChange = (eventType: 'change' | 'rename'): void => {
+ if (tailWatches.get(key) !== subscription || sender.isDestroyed()) {
+ return
+ }
+ const payload: LocalLogTailChangedPayload = { subscriptionId, eventType }
+ sender.send('fs:localLogTailChanged', payload)
+ }
+ const watcher = watch(filePath, (eventType) => sendChange(eventType))
+ const subscription: TailWatch = { owner, watcher }
+ watcher.on('error', () => {
+ // Rotation needs one final drain before releasing this exact watcher.
+ sendChange('rename')
+ closeWatch(key, subscription)
+ })
+ tailWatches.set(key, subscription)
+ owner.watchKeys.add(key)
+ } finally {
+ if (owner.pending.get(key) === pending) {
+ owner.pending.delete(key)
+ }
+ releaseIdleOwner(owner)
+ }
}
export function registerLocalLogTailHandlers(store: Store): void {
@@ -64,43 +152,25 @@ export function registerLocalLogTailHandlers(store: Store): void {
}
)
- ipcMain.handle(
- 'fs:startLocalLogTail',
- async (event, args: LocalLogTailWatchArgs): Promise<void> => {
- const subscriptionId = validateSubscriptionId(args.subscriptionId)
- const filePath = await resolveAuthorizedPath(args.filePath, store)
- const key = watchKey(event.sender.id, subscriptionId)
- closeWatch(key)
-
- const sendChange = (eventType: 'change' | 'rename'): void => {
- if (!tailWatches.has(key) || event.sender.isDestroyed()) {
- return
- }
- const payload: LocalLogTailChangedPayload = { subscriptionId, eventType }
- event.sender.send('fs:localLogTailChanged', payload)
- }
- const watcher = watch(filePath, (eventType) => sendChange(eventType))
- watcher.on('error', () => {
- // Why: an error commonly accompanies rotation. Signal one final drain so
- // the renderer can detect identity change, then release the dead handle.
- sendChange('rename')
- closeWatch(key)
- })
- tailWatches.set(key, { senderId: event.sender.id, watcher })
- registerSenderCleanup(event.sender)
- }
+ ipcMain.handle('fs:startLocalLogTail', (event, args: LocalLogTailWatchArgs): Promise<void> =>
+ startWatch(event.sender, args, store)
)
ipcMain.handle('fs:stopLocalLogTail', (event, args: { subscriptionId: string }): void => {
- closeWatch(watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId)))
+ const key = watchKey(event.sender.id, validateSubscriptionId(args.subscriptionId))
+ const owner = senderOwners.get(event.sender.id)
+ owner?.pending.delete(key)
+ closeWatch(key)
+ if (owner) {
+ releaseIdleOwner(owner)
+ }
})
}
export function closeAllLocalLogTailWatchers(): void {
- for (const key of Array.from(tailWatches.keys())) {
- closeWatch(key)
+ for (const owner of senderOwners.values()) {
+ closeSenderWatches(owner)
}
- senderCleanupRegistered.clear()
}
/** Test-only: verifies tab/window teardown does not retain native watchers. */
@@ -0,0 +1,146 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { applyPatch, parsePatch, reversePatch } from 'diff'
import { build } from 'esbuild'
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
}
const root = fileURLToPath(new URL('../../../', import.meta.url))
const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8')
const beforeSources = {}
const sourceHashes = {}
for (const parsed of parsePatch(patch)) {
const path = parsed.newFileName.replace(/^b\//, '')
const absolute = resolve(root, path)
const current = await readFile(absolute, 'utf8')
const before = applyPatch(current, reversePatch(parsed))
if (before === false) {
throw new Error(`Source changed; review the proof patch: ${path}`)
}
beforeSources[absolute.replaceAll('\\', '/')] = before
sourceHashes[path] = {
before: createHash('sha256').update(before).digest('hex'),
after: createHash('sha256').update(current).digest('hex')
}
}
for (const path of [
'src/main/ipc/local-log-tail-lifetime.test.ts',
'src/main/ipc/local-log-tail.test.ts'
]) {
sourceHashes[path] = {
current: createHash('sha256')
.update(await readFile(resolve(root, path)))
.digest('hex')
}
}
const scratch = await mkdtemp(join(tmpdir(), 'orca-local-log-tail-lifetime-'))
const require = createRequire(import.meta.url)
let runnerModuleId
try {
const runnerPath = join(scratch, 'run-process.cjs')
await build({
absWorkingDir: root,
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
outfile: runnerPath,
bundle: true,
platform: 'node',
format: 'cjs',
logLevel: 'silent'
})
runnerModuleId = require.resolve(runnerPath)
const { runProcess } = require(runnerModuleId)
const baselineConfig = join(scratch, 'before.config.mjs')
const fixedConfig = join(scratch, 'after.config.mjs')
const includes = [
'src/main/ipc/local-log-tail-lifetime.test.ts',
'src/main/ipc/local-log-tail.test.ts'
]
const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href)
await writeFile(
baselineConfig,
`import base from ${configImport};
const beforeSources = ${JSON.stringify(beforeSources)};
export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{
name: 'local-log-lifetime-before-fix', enforce: 'pre',
transform(_code, id) {
const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]];
return before === undefined ? null : {code: before, map: null};
}
}]};\n`
)
await writeFile(
fixedConfig,
`import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n`
)
async function run(label, config) {
const report = join(scratch, `${label}.json`)
const result = await runProcess({
program: process.execPath,
args: [
resolve(root, 'node_modules/vitest/vitest.mjs'),
'run',
'--config',
config,
'--reporter=json',
`--outputFile=${report}`
],
cwd: root,
env: process.env,
timeoutMs: 90_000,
maxOutputBytes: 4 * 1024 * 1024
})
let parsed
try {
parsed = JSON.parse(await readFile(report, 'utf8'))
} catch (error) {
throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error })
}
return {
exitCode: result.code,
passed: parsed.numPassedTests,
failed: parsed.numFailedTests,
failedCases: parsed.testResults.flatMap((suite) =>
suite.assertionResults
.filter((test) => test.status === 'failed')
.map((test) => test.fullName)
)
}
}
const before = await run('before', baselineConfig)
const after = await run('after', fixedConfig)
const passed =
before.failed === 10 && before.passed === 9 && after.passed === 19 && after.failed === 0
console.log(
JSON.stringify(
{
comparison:
'Actual local log IPC lifecycle tests with real temporary-file watchers and controlled sender events; before reverses only fix.patch in a temporary Vite transform',
sourceHashes,
before,
after,
passed
},
null,
2
)
)
if (!passed) {
process.exitCode = 1
}
} finally {
if (runnerModuleId) {
delete require.cache[runnerModuleId]
}
await rm(scratch, { recursive: true, force: true })
}
@@ -0,0 +1,39 @@
{
"comparison": "Actual local log IPC lifecycle tests with real temporary-file watchers and controlled sender events; before reverses only fix.patch in a temporary Vite transform",
"sourceHashes": {
"src/main/ipc/local-log-tail.ts": {
"before": "6c7b9912fdab5be8b219eacc5000f2e11097219832212ec2f532879222292c02",
"after": "e5db5f0256dd1c2d8d6b42039f2ad5f2cf46faa9e2edeb6f522a962fa58fbf81"
},
"src/main/ipc/local-log-tail-lifetime.test.ts": {
"current": "2ed1a7f9a1a0ddaf724b429aaa2ec1f9c531dda1c6e82c9884e8d85f25877ef6"
},
"src/main/ipc/local-log-tail.test.ts": {
"current": "eafa0ccdf60d7adbc14ed9b14ca04c27e775a55c77996e5b2894f448a126637c"
}
},
"before": {
"exitCode": 1,
"passed": 9,
"failed": 10,
"failedCases": [
"does not install a watcher after its sender is destroyed during authorization",
"does not revive an existing subscription while a replacement is authorizing at destruction",
"rejects both overlapping same-ID admissions after renderer destruction",
"releases installed and pending watches on render-process-gone and permits a new document owner",
"releases installed and pending watches on did-navigate and permits a new document owner",
"shares lifecycle listeners and releases them when the last watch stops",
"explicit stop invalidates pending authorization without retaining idle listeners",
"late success from an older same-ID request preserves the newer installed watch",
"does not accumulate watchers across twenty destroyed renderer owners",
"local log tail IPC ignores errors from a retired watcher after a same-ID replacement"
]
},
"after": {
"exitCode": 0,
"passed": 19,
"failed": 0,
"failedCases": []
},
"passed": true
}
@@ -0,0 +1,38 @@
# Closing an unbound paired-runtime pane with a captured handle
A restored pane can already hold a scoped `remote:<environment>@@<handle>` layout binding while `remote.attach()` waits for `terminal.resolvePane`. The transport's `getPtyId()` is still null. An explicit split close therefore passed null to `closeWebRuntimeTerminal`, removed the layout binding, and destroyed only the viewer. The host terminal stayed connected. This attachment/teardown behavior exists in `v1.4.198`.
This is a specific retained host-terminal mechanism. The change is stacked on the local/direct-SSH pending-close fix in [#21001](https://github.com/stablyai/orca/pull/21001) and reuses its current-owner query. It does not prove the incident frequency in [#15210](https://github.com/stablyai/orca/issues/15210), Linux Electron-main growth, or [#19831](https://github.com/stablyai/orca/issues/19831)'s memory slope.
## Scope and authority
Only an exact scoped handle whose environment matches the owning workspace's runtime authorizes this fix. Existing retirement planning and the shared current-owner query protect other tabs, sibling aliases, and bound transports. The provider helper captures the pairing revision, performs its existing compatibility check, then checks pairing and current ownership again immediately before dispatch. The second call skips only the check that just completed. It sends the existing `terminal.close` request for the captured handle.
The actual host fixture verifies that re-registering the same PTY ID with a new incarnation allocates a new handle. A close addressed to the old handle rejects and never invokes the controller's kill operation. Client same-leaf adoption, a replaced transport map, and changed worktree/pairing ownership also suppress the queued request. Ordinary detach remains viewer-only.
Native host PTY hints, legacy handles without an explicit environment, and returned different handles are outside this fix. Current client snapshot registries retain freshness/frame identity rather than a live terminal-row incarnation. Inferring destructive authority from a late native-hint resolution could stop a replacement. The separate read-only native-hint and pending web-activation reproduction remains in `notes/paired-pending-split-close`; it establishes omitted requests, with no claim that these excluded cases are fixed. No parent-tab close, local fallback, new wire field, or capability is introduced. A request is not confirmation of process death; provider failures retain their existing handling.
## Close-confirmation review correction
The public split-close callback now probes the captured scoped handle before authorizing retirement, including while `terminal.resolvePane` remains pending. Live or unverified pending work opens the existing confirmation dialog. Cancel keeps the host terminal; Confirm rechecks the captured tab, pane, transport, handle, host, and pairing revision. A replacement or a split that became the only pane invalidates the old decision. The host's existing handle-incarnation fence and the compatibility-dispatch checks remain in force.
`pending-pane-close-confirmation.test.ts` adds public-callback controls for both local/direct-SSH and paired pending panes. The comparative counts below remain the original proof snapshot, which called the post-confirmation `executeClosePane` callback directly.
## Reproduce
Run in the repository root with existing dependencies:
```sh
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/pending-runtime-pane-close/reproduce.mjs
```
The script runs 13 tests using the actual split-close hook and remote transport, plus two tests delivering the close RPC into an actual `OrcaRuntimeService` with a fake PTY controller. React registration and unrelated presentation callbacks are mocked. No Electron window, host process inventory, or real PTY child is used.
The temporary Vite transform reverses only `fix.patch`; the baseline includes the IPC fix from #21001. Working sources remain untouched. The script uses the shared cross-platform process runner and records source hashes and exact cases in `results.json`.
| Version | Passed | Failed |
| ------------------------ | -----: | -----: |
| Before scoped-handle fix | 5 | 10 |
| With scoped-handle fix | 15 | 0 |
The baseline failure count includes new eager-request/compatibility assertions, not ten independent leaks. Additional validation: 255 tests in 21 selected renderer suites, full renderer typecheck, direct lint, and the changed-code quality gate pass. The original 24-case IPC proof still runs after the shared ownership extraction; its committed results remain a snapshot of the published IPC source.
@@ -0,0 +1,179 @@
diff --git a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts
index 081a33fc895..a3235fea66a 100644
--- a/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts
+++ b/src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts
@@ -1,21 +1,16 @@
-import type { AppState } from '@/store/types'
import {
buildTerminalTabRetirementPlan,
- getTerminalPtyOwnershipIdentity,
- hasTerminalPtyOwnerOutsidePane
+ getTerminalPtyOwnershipIdentity
} from '@/store/slices/terminal-tab-retirement'
import { startTerminalTabProviderRetirement } from '@/store/terminals/terminal-tab-close-providers'
-import type { PtyTransport } from './pty-transport-types'
+import {
+ terminalPaneHasOtherOwner,
+ type UnboundTerminalPaneRetirement
+} from './terminal-pane-retirement-ownership'
/** Capture explicit split-close intent before the durable leaf binding is removed. */
-export function retireUnboundIpcTerminalPane(args: {
- getState: () => AppState
- tabId: string
- leafId: string
- transport: PtyTransport | undefined
- getTransports: () => ReadonlyMap<number, PtyTransport>
-}): void {
- const { getState, tabId, leafId, transport, getTransports } = args
+export function retireUnboundIpcTerminalPane(args: UnboundTerminalPaneRetirement): void {
+ const { getState, tabId, leafId, transport } = args
if (!transport || transport.getPtyId()) {
return
}
@@ -33,19 +28,8 @@ export function retireUnboundIpcTerminalPane(args: {
if (!ptyId) {
return
}
- const hasOtherOwner = (excludedLeafId?: string): boolean => {
- const current = getState()
- return (
- hasTerminalPtyOwnerOutsidePane(current, identity, tabId, excludedLeafId) ||
- [...getTransports().values()].some((candidate) => {
- const boundId = candidate.getPtyId()
- return (
- boundId !== null &&
- getTerminalPtyOwnershipIdentity(current, boundId, plan.worktreeId) === identity
- )
- })
- )
- }
+ const hasOtherOwner = (excludedLeafId?: string): boolean =>
+ terminalPaneHasOtherOwner(args, identity, plan.worktreeId, excludedLeafId)
if (hasOtherOwner(leafId)) {
return
}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
index ea85e929e81..3e8dd463a30 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
@@ -1,5 +1,6 @@
import { useCallback, useImperativeHandle, useRef } from 'react'
import { useAppStore } from '../../store'
+import { retireUnboundRuntimeTerminalPane } from './retire-unbound-runtime-terminal-pane'
import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session'
@@ -61,6 +62,13 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
}
setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId))
if (leafId) {
+ retireUnboundRuntimeTerminalPane({
+ getState: useAppStore.getState,
+ tabId,
+ leafId,
+ transport: paneTransportsRef.current.get(paneId),
+ getTransports: () => paneTransportsRef.current
+ })
syncPanePtyLayoutBindingForLeaf?.(leafId, null, paneId)
} else {
syncPanePtyLayoutBinding(paneId, null)
diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts
index eb04233cc91..719e54eac89 100644
--- a/src/renderer/src/runtime/runtime-rpc-client.ts
+++ b/src/renderer/src/runtime/runtime-rpc-client.ts
@@ -95,7 +95,7 @@ export async function callRuntimeRpc<TResult>(
return unwrapRuntimeRpcResult<TResult>(response as RuntimeRpcResponse<TResult>)
}
-async function ensureRuntimeEnvironmentCompatible(
+export async function ensureRuntimeEnvironmentCompatible(
environmentId: string,
options: {
timeoutMs?: number
diff --git a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts
index 322d4106c7e..4ba425d95d2 100644
--- a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts
+++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts
@@ -1,5 +1,9 @@
+import {
+ captureRuntimeEnvironmentRequestRevision,
+ getRuntimeEnvironmentRevision
+} from '@/runtime/runtime-environment-revision'
import type { AppState } from '../types'
-import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import { callRuntimeRpc, ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client'
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
import {
classifyTerminalRetirementWorktree,
@@ -11,13 +15,15 @@ export function startTerminalTabProviderRetirement({
remoteCloseOwnedByHost,
retirementPlan,
state,
- tabId
+ tabId,
+ canRetireRuntimeTerminal
}: {
localPtyTeardownOwnedExternally: boolean
remoteCloseOwnedByHost: boolean
retirementPlan: TerminalTabRetirementPlan
state: AppState
tabId: string
+ canRetireRuntimeTerminal?: () => boolean
}): void {
const fallbackWorktreeRoute = retirementPlan.worktreeId
? resolveTerminalWorktreeRoute(state, retirementPlan.worktreeId)
@@ -33,11 +39,7 @@ export function startTerminalTabProviderRetirement({
}
const environmentId = terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId
retirementTasks.push(
- callRuntimeRpc(
- environmentId ? { kind: 'environment', environmentId } : { kind: 'local' },
- 'terminal.close',
- { terminal: terminal.handle }
- )
+ retireRuntimeTerminal(environmentId, terminal.handle, canRetireRuntimeTerminal)
)
}
}
@@ -66,3 +68,40 @@ export function startTerminalTabProviderRetirement({
}
})
}
+
+async function retireRuntimeTerminal(
+ environmentId: string | null | undefined,
+ handle: string,
+ canRetire?: () => boolean
+): Promise<unknown> {
+ const target = environmentId
+ ? { kind: 'environment' as const, environmentId }
+ : { kind: 'local' as const }
+ if (!canRetire) {
+ return callRuntimeRpc(target, 'terminal.close', { terminal: handle })
+ }
+ const revision = environmentId
+ ? captureRuntimeEnvironmentRequestRevision(environmentId)
+ : undefined
+ if (environmentId) {
+ await ensureRuntimeEnvironmentCompatible(environmentId, {
+ expectedEnvironmentPairingRevision: revision
+ })
+ }
+ if (
+ (environmentId && getRuntimeEnvironmentRevision(environmentId) !== revision) ||
+ !canRetire()
+ ) {
+ return
+ }
+ // Compatibility was checked above; recheck pane ownership at the actual dispatch boundary.
+ return callRuntimeRpc(
+ target,
+ 'terminal.close',
+ { terminal: handle },
+ {
+ skipCompatibilityCheck: true,
+ expectedEnvironmentPairingRevision: revision
+ }
+ )
+}
@@ -0,0 +1,70 @@
import { expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime'
import { preparePendingRuntimeClose } from '../../../src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture'
it.each([false, true])(
'actual close RPC addresses only the captured host incarnation: replacement=%s',
async (replacement) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the only store method used by the exercised register/resolve/close path.
const store = { getRepos: () => [] } as unknown as ConstructorParameters<
typeof OrcaRuntimeService
>[0]
const runtime = new OrcaRuntimeService(store)
const kill = vi.fn((id: string) => {
runtime.onPtyExit(id, 0)
return true
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The real close method uses the supplied kill operation; this fixture launches no subprocess.
runtime.setPtyController({ kill } as Parameters<typeof runtime.setPtyController>[0])
const binding = {
tabId: 'tab-parent',
leafId: '11111111-1111-4111-8111-111111111111',
incarnationId: '11111111-1111-4111-8111-111111111111'
}
runtime.registerPty('host-pty', 'workspace', null, binding)
const paneKey = `${binding.tabId}:${binding.leafId}`
const original = runtime.resolveTerminalPane(paneKey, 'workspace')
const p = await preparePendingRuntimeClose(`remote:env-1@@${original.handle}`)
const beforeCall = p.runtimeCall.getMockImplementation()!
p.runtimeCall.mockImplementation(async (request) => {
if (request.method !== 'terminal.close') {
return beforeCall(request)
}
const params = request.params
if (
!params ||
typeof params !== 'object' ||
!('terminal' in params) ||
typeof params.terminal !== 'string'
) {
throw new Error('expected captured terminal handle')
}
return { ok: true, result: { close: await runtime.closeTerminal(params.terminal) } }
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
p.actions.executeClosePane(1)
if (replacement) {
runtime.registerPty('host-pty', 'workspace', null, {
...binding,
incarnationId: '22222222-2222-4222-8222-222222222222'
})
expect(runtime.resolveTerminalPane(paneKey, 'workspace').handle).not.toBe(original.handle)
}
p.acceptCompatibility()
await p.settle(original.handle)
expect(p.runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.close', params: { terminal: original.handle } })
)
if (replacement) {
expect(kill).not.toHaveBeenCalled()
expect(runtime.resolveTerminalPane(paneKey, 'workspace').connected).toBe(true)
} else {
expect(kill).toHaveBeenCalledExactlyOnceWith('host-pty')
}
expect(window.api.pty.kill).not.toHaveBeenCalled()
} finally {
runtime.onPtyExit('host-pty', 0)
}
}
)
@@ -0,0 +1,153 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { applyPatch, parsePatch, reversePatch } from 'diff'
import { build } from 'esbuild'
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
}
const root = fileURLToPath(new URL('../../../', import.meta.url))
const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8')
const beforeSources = {}
const sourceHashes = {}
for (const parsed of parsePatch(patch)) {
const path = parsed.newFileName.replace(/^b\//, '')
const absolute = resolve(root, path)
const current = await readFile(absolute, 'utf8')
const before = applyPatch(current, reversePatch(parsed))
if (before === false) {
throw new Error(`Source changed; review the proof patch: ${path}`)
}
beforeSources[absolute.replaceAll('\\', '/')] = before
sourceHashes[path] = {
before: createHash('sha256').update(before).digest('hex'),
after: createHash('sha256').update(current).digest('hex')
}
}
for (const path of [
'src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts',
'src/renderer/src/components/terminal-pane/terminal-pane-retirement-ownership.ts',
'src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture.ts',
'src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts',
'docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts'
]) {
sourceHashes[path] = {
current: createHash('sha256')
.update(await readFile(resolve(root, path)))
.digest('hex')
}
}
const scratch = await mkdtemp(join(tmpdir(), 'orca-pending-runtime-close-'))
const require = createRequire(import.meta.url)
let runnerModuleId
try {
const runnerPath = join(scratch, 'run-process.cjs')
await build({
absWorkingDir: root,
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
outfile: runnerPath,
bundle: true,
platform: 'node',
format: 'cjs',
logLevel: 'silent'
})
runnerModuleId = require.resolve(runnerPath)
const { runProcess } = require(runnerModuleId)
const baselineConfig = join(scratch, 'before.config.mjs')
const fixedConfig = join(scratch, 'after.config.mjs')
const includes = [
'src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts',
'docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts'
]
const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href)
await writeFile(
baselineConfig,
`import base from ${configImport};
const beforeSources = ${JSON.stringify(beforeSources)};
export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{
name: 'pending-runtime-close-before-fix', enforce: 'pre',
transform(_code, id) {
const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]];
return before === undefined ? null : {code: before, map: null};
}
}]};\n`
)
await writeFile(
fixedConfig,
`import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n`
)
async function run(label, config) {
const report = join(scratch, `${label}.json`)
const result = await runProcess({
program: process.execPath,
args: [
resolve(root, 'node_modules/vitest/vitest.mjs'),
'run',
'--config',
config,
'--reporter=json',
`--outputFile=${report}`
],
cwd: root,
env: process.env,
timeoutMs: 90_000,
maxOutputBytes: 4 * 1024 * 1024
})
let parsed
try {
parsed = JSON.parse(await readFile(report, 'utf8'))
} catch (error) {
throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error })
}
return {
exitCode: result.code,
passed: parsed.numPassedTests,
failed: parsed.numFailedTests,
failedCases: parsed.testResults.flatMap((suite) =>
suite.assertionResults
.filter((test) => test.status === 'failed')
.map((test) => test.fullName)
)
}
}
const before = await run('before', baselineConfig)
const after = await run('after', fixedConfig)
const passed =
before.failed === 10 &&
before.passed === 5 &&
before.passed + before.failed === 15 &&
after.passed === 15 &&
after.failed === 0
console.log(
JSON.stringify(
{
comparison:
'Actual split close/IPC transport and actual host handle-close tests; before reverses only fix.patch in a temporary Vite transform',
sourceHashes,
before,
after,
passed
},
null,
2
)
)
if (!passed) {
process.exitCode = 1
}
} finally {
if (runnerModuleId) {
delete require.cache[runnerModuleId]
}
await rm(scratch, { recursive: true, force: true })
}
@@ -0,0 +1,60 @@
{
"comparison": "Actual split close/IPC transport and actual host handle-close tests; before reverses only fix.patch in a temporary Vite transform",
"sourceHashes": {
"src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts": {
"before": "3194229bdd3c992e8459cdad727a3931653953b204854486b8aaf4d129152d24",
"after": "f52f4b50d94e71506921788e3b49db547dbd879569d80151429adaeaa5865571"
},
"src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts": {
"before": "bbfeb2385fd120a7a1407d043011fb689b5c2b652c31150de44061f1edb76a7b",
"after": "d9f0c08d82e70180f4e3c28ca33815314c580ba59c65cb3c5004079b56c3f227"
},
"src/renderer/src/runtime/runtime-rpc-client.ts": {
"before": "d0ae689153ba0d972ba7a10484c2024bf9fd08c8628bd43009396aa0f653058e",
"after": "37912cebd375be8378a8a882cf5677663d435414cb81bec5180637df7165f2b9"
},
"src/renderer/src/store/terminals/terminal-tab-close-providers.ts": {
"before": "87b666644adc3b3295b848b424f44b5834980c7871d7df55bbda6fabeb4c7c7b",
"after": "66f5853c4dc1a14bee7b630a2d868ae3432a8d4870b3e5022a4a7138469d6579"
},
"src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts": {
"current": "9e7fe08d0d32e75b9d01cb56c6fcffef48443e57b1d8d183377120ce944a1ae7"
},
"src/renderer/src/components/terminal-pane/terminal-pane-retirement-ownership.ts": {
"current": "a7aa072930b2e293ca49701df355b60e52ad8b8bb1c8449239e0777f8d8c3020"
},
"src/renderer/src/components/terminal-pane/pending-runtime-pane-close-test-fixture.ts": {
"current": "2e08dec20d64d2f21962fb6a04ee0783d49cf2db1c0bc3d407e761a7f5515c7a"
},
"src/renderer/src/components/terminal-pane/pending-runtime-pane-close.test.ts": {
"current": "f1477f53e086330c7587c2e0c5f13070917bb84b4b249de1d31045c3f03fb2f3"
},
"docs/audits/pending-runtime-pane-close/host-handle-proof.test.ts": {
"current": "becc10e549a97e4a45d03f93de726ee7989f11fc6cb118bc362c7eab4809d8c7"
}
},
"before": {
"exitCode": 1,
"passed": 5,
"failed": 10,
"failedCases": [
"actual close RPC addresses only the captured host incarnation: replacement=false",
"actual close RPC addresses only the captured host incarnation: replacement=true",
"closes the captured scoped handle while actual remote attach is still unbound",
"rechecks same-leaf ownership after compatibility settles",
"rechecks other-tab ownership after compatibility settles",
"rechecks bound-transport ownership after compatibility settles",
"rechecks worktree-owner ownership after compatibility settles",
"rechecks pairing ownership after compatibility settles",
"does not bypass a failed compatibility check",
"never turns a late different resolved handle into close authority"
]
},
"after": {
"exitCode": 0,
"passed": 15,
"failed": 0,
"failedCases": []
},
"passed": true
}
+42
View File
@@ -0,0 +1,42 @@
# Pending split close can omit shell retirement
A restored split pane can be explicitly closed while its IPC spawn/reattach reply is pending. Its transport has no bound PTY ID yet. The old close path deleted the durable leaf binding and destroyed the unbound transport without requesting retirement. The late-result cleanup deliberately preserved reattach and cold-restore replies for remounts, so an existing shell or newly cold-restored shell could remain live with no pane and no kill request.
This is a concrete mechanism matching the **missing kill requests** in [#15210](https://github.com/stablyai/orca/issues/15210). The relevant close, late-result exclusion, and daemon cold-restore behavior exists in both `v1.4.184` and `v1.4.198`. The report does not establish that this sequence produced its 51 shells. It also does not establish a retained Electron-main heap slope or independently explain [#19831](https://github.com/stablyai/orca/issues/19831).
## Ownership and fix
The explicit split-close hook captures its durable requested PTY before removing the leaf binding. Existing tab-retirement planning resolves the local/direct-SSH execution owner. It requests retirement immediately and retains an explicit-close callback for the late same-ID result. The late callback rechecks current store ownership and the current transport map, including a replacement at the same leaf. It makes a second request only when no owner remains.
An eager request alone is insufficient: Electron spawn preflight can still be waiting before the adapter/history lock exists. A kill can return `SessionNotFoundError`, then the pending spawn creates a new cold-restored shell. The late-result request closes that window. If spawn already owns the adapter's history lock, the ordinary known-ID shutdown waits behind it; the control case preserves that behavior.
Generic detach/destroy keeps its existing behavior. Repeated destroy retains explicit intent, while a different returned reattach identity remains protected. The tab aggregate/row ID is not counted as a separate same-tab owner; it can be the closing leaf's own stale index. Other live tabs use all existing retirement ownership sources; remote alias matching reuses the existing normalized identity.
Paired-runtime handles, runtime-owned native hints, and unresolved owners never fall through to local kill. They are outside this IPC fix. A provider failure is logged by the existing retirement helper; requesting retirement is not confirmation of process death. No host inventory sweep, wire change, global tombstone, or remount-driven shutdown is introduced. A shared owner already present at close keeps its established retirement responsibility; this does not change all pending-fresh/shared-owner races.
## Close-confirmation review correction
The original tests called `executeClosePane` after a close decision. A separate review found that the public `handleRequestClosePane` callback skipped the running-work check while the transport was still unbound. Retirement now obtains the pending local/direct-SSH identity from the existing retirement plan and runs the existing confirmation flow first. An unverified pending probe asks for confirmation; Cancel preserves the process. Before a delayed decision or confirmation acts, the captured tab generation, pane, transport, binding, and execution owner must still match. A split that became the final pane cannot turn an old confirmation into a whole-tab close.
`pending-pane-close-confirmation.test.ts` exercises the public callbacks, including live and unverified work, Cancel, confirmed close, completed attachment, ownership replacement, and direct SSH. These are separate regression controls added after the original comparative proof below; they do not change its historical case counts.
## Reproduce
From the repository root with the existing dependencies installed:
```sh
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/pending-split-close/reproduce.mjs
```
The script runs the actual close hook, layout binding, pane-close handler, IPC transport, daemon server, and daemon adapter. React registration and unrelated presentation/status callbacks are mocked. Daemon cases use temporary sockets, synthetic history, and the existing fake subprocess fixture; no real shell or Orca window is launched. It cleans its temporary configuration and invokes Vitest through the repository's cross-platform `runProcess`.
`fix.patch` is reversed only inside a temporary Vite source transform for the baseline. Working files remain unchanged. New helper/test sources remain present, but the baseline close hook cannot call the helper. Source hashes and exact cases are recorded in `results.json`:
| Version | Passed | Failed |
| --- | ---: | ---: |
| Before fix | 10 | 14 |
| With fix | 24 | 0 |
The 24 cases retain the original nine reproduction/control scenarios and add same-leaf/new-map/different-tab adoption, sibling ownership, repeated destroy, provider failure/retry, spawn rejection, returned-ID mismatch, direct SSH, unresolved/paired-runtime routing, and folder workspace coverage. Eight separate ownership-query tests cover legacy/scoped aliases and the existing tab ownership sources. The baseline failures include assertions about the new eager request; they are not 14 independent leaks.
Historical entry points: `v1.4.184` `TerminalPane.tsx:1163`, `use-terminal-pane-lifecycle.ts:1368`, `pty-transport.ts:859`, and `src/main/daemon/daemon-pty-adapter.ts:750`. The current equivalents are `use-terminal-pane-close-actions.ts`, `terminal-pane-pane-closed.ts`, `ipc-pty-connect.ts`, and `daemon-pty-spawn-result.ts`.
@@ -0,0 +1,132 @@
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { expect, it, vi } from 'vitest'
import { preparePendingSplitClose } from '../../../src/renderer/src/components/terminal-pane/pending-split-close-test-fixture'
import {
startDaemonAdapterHarness,
createMockSubprocess
} from '../../../src/main/daemon/daemon-pty-adapter-test-harness'
import { DaemonPtyAdapter } from '../../../src/main/daemon/daemon-pty-adapter'
import { getHistorySessionDirName } from '../../../src/main/daemon/history-paths'
import { SessionNotFoundError } from '../../../src/main/daemon/types'
async function coldRestoreHarness() {
const subprocess = createMockSubprocess()
const h = await startDaemonAdapterHarness(() => subprocess)
const historyPath = join(h.dir, 'history')
const sessionDir = join(historyPath, getHistorySessionDirName('pty-restored'))
mkdirSync(sessionDir, { recursive: true })
writeFileSync(
join(sessionDir, 'meta.json'),
JSON.stringify({
cwd: h.dir,
cols: 80,
rows: 24,
startedAt: '2026-04-15T10:00:00Z',
endedAt: null,
exitCode: null
})
)
writeFileSync(join(sessionDir, 'scrollback.bin'), 'synthetic saved history\r\n')
const adapter = new DaemonPtyAdapter({
socketPath: h.socketPath,
tokenPath: h.tokenPath,
historyPath
})
const requests: Promise<void>[] = []
const absent: string[] = []
const bridgeKill = (id: string): Promise<void> => {
const request = adapter.shutdown(id, { immediate: true }).catch((error) => {
// The renderer IPC handler treats the provider's already-gone reply as success.
if (error instanceof SessionNotFoundError) {
absent.push(id)
return
}
throw error
})
requests.push(request)
return request
}
return {
adapter,
subprocess,
requests,
absent,
bridgeKill,
async dispose() {
adapter.dispose()
h.adapter.dispose()
await h.server.shutdown()
rmSync(h.dir, { recursive: true, force: true })
}
}
}
it('explicit split close retires a real daemon cold restore whose reply is pending', async () => {
const h = await coldRestoreHarness()
try {
const p = await preparePendingSplitClose()
vi.mocked(window.api.pty.kill).mockImplementation(h.bridgeKill)
const result = await h.adapter.spawn({ cols: 80, rows: 24, sessionId: 'pty-restored' })
expect(result.isReattach).not.toBe(true)
expect(result.coldRestore).toBeDefined()
expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(true)
p.actions.executeClosePane(1)
p.spawn.resolve(result)
await p.connecting
await Promise.all(h.requests)
expect(window.api.pty.kill).toHaveBeenCalledTimes(2)
expect(h.subprocess.forceKill).toHaveBeenCalledOnce()
expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(false)
} finally {
await h.dispose()
}
})
it('the explicit late reply retries a kill that completed before adapter admission', async () => {
const h = await coldRestoreHarness()
try {
const p = await preparePendingSplitClose()
vi.mocked(window.api.pty.kill).mockImplementation(h.bridgeKill)
p.actions.executeClosePane(1)
// Hold admission outside the adapter; there is no history lock or session yet.
await Promise.all(h.requests)
expect(h.absent).toEqual(['pty-restored'])
const result = await h.adapter.spawn({ cols: 80, rows: 24, sessionId: 'pty-restored' })
expect(result.coldRestore).toBeDefined()
expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(true)
p.spawn.resolve(result)
await p.connecting
await Promise.all(h.requests)
expect(window.api.pty.kill).toHaveBeenCalledTimes(2)
expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(false)
} finally {
await h.dispose()
}
})
it('a known-ID shutdown already admitted to the adapter waits for its spawn history lock', async () => {
const h = await coldRestoreHarness()
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const finish = h.adapter['finishSpawn'].bind(h.adapter)
h.adapter['finishSpawn'] = async (context, result) => {
started.resolve()
await release.promise
return finish(context, result)
}
try {
const spawning = h.adapter.spawn({ cols: 80, rows: 24, sessionId: 'pty-restored' })
await started.promise
const stopping = h.adapter.shutdown('pty-restored', { immediate: true })
expect(h.subprocess.forceKill).not.toHaveBeenCalled()
release.resolve()
await spawning
await stopping
expect(await h.adapter.probePtyLiveness('pty-restored')).toBe(false)
expect(h.subprocess.forceKill).toHaveBeenCalledOnce()
} finally {
release.resolve()
await h.dispose()
}
})
+237
View File
@@ -0,0 +1,237 @@
diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts
index 3023236de0e..52b983c51fd 100644
--- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts
+++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts
@@ -27,6 +27,7 @@ type IpcPtyConnectContext = {
/** True only for the one buffered exit consumed by this connect attempt. */
isExpectedExitCurrent: () => boolean
ownsPtyId: (id: string) => boolean
+ handleExplicitlyClosedConnect?: (id: string) => boolean
bind: (id: string) => void
isCurrent: (id: string) => boolean
setCallbacks: (callbacks: PtyConnectOptions['callbacks']) => void
@@ -89,6 +90,9 @@ export async function connectIpcPty(
const priorIncarnationFence = currentPreHandlerPtySequence()
const spawnResult = await spawnIpcPty(transportOptions, options, admittedSessionId)
const retireFreshSpawn = async (): Promise<void> => {
+ if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) {
+ return
+ }
// A newer generation may already own a recycled id; an id-only kill would retire its PTY.
if (
!spawnResult.isReattach &&
diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts
index 4d7eaf7358b..2f6ed734bbf 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts
@@ -232,7 +232,10 @@ export type PtyTransport = {
* it also drops the transport's output processor from the pty side-effect memory census,
* so a reattached one would run untracked. Create a new transport instead. */
detach?: (options?: { preserveExitObserver?: boolean }) => void
- destroy?: () => void | Promise<void>
+ destroy?: (options?: {
+ /** Explicit close can retain retirement intent until an unbound connect settles. */
+ onAbandonedConnect?: (ptyId: string) => boolean
+ }) => void | Promise<void>
}
export type IpcPtyTransportOptions = {
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts
index f794b9a1e4d..7731e75eac5 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.ts
@@ -44,6 +44,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
} = opts
let connected = false
let destroyed = false
+ let onAbandonedConnect: ((ptyId: string) => boolean) | undefined
let ptyId: string | null = null
let lifecycleGeneration = 0
let lastExitGeneration: number | null = null
@@ -137,6 +138,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
lastExitGeneration === lifecycleGeneration &&
lifecycleGeneration === connectGeneration + 1,
ownsPtyId: (id) => !destroyed && connected && ptyId === id,
+ handleExplicitlyClosedConnect: (id) => destroyed && (onAbandonedConnect?.(id) ?? false),
bind,
isCurrent: (id) => lifecycleGeneration === connectGeneration && connected && ptyId === id,
setCallbacks,
@@ -268,7 +270,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
: { ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(shellOverride ? { shellOverride } : {}) },
resetCrossChunkParserState: outputProcessor.resetAgentStatusCarry,
- destroy() {
+ destroy(options) {
+ onAbandonedConnect ??= options?.onAbandonedConnect
destroyed = true
try {
this.disconnect()
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
index 330b42166cd..ea85e929e81 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
@@ -1,4 +1,4 @@
-import { useCallback, useImperativeHandle } from 'react'
+import { useCallback, useImperativeHandle, useRef } from 'react'
import { useAppStore } from '../../store'
import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager'
import { makePaneKey } from '../../../../shared/stable-pane-id'
@@ -13,8 +13,11 @@ import {
} from './terminal-pane-tab-detach'
import { clearPaneTerminalError } from './terminal-error-accumulation'
import type { TerminalPaneBindingController } from './use-terminal-pane-layout-bindings'
+import { retireUnboundIpcTerminalPane } from './retire-unbound-ipc-terminal-pane'
+import { capturePendingTerminalPaneClose } from './terminal-pane-close-admission'
export function useTerminalPaneCloseActions(controller: TerminalPaneBindingController) {
+ const confirmedCloseRef = useRef<(() => void) | null>(null)
const {
clearSessionRestoredBannerForPane,
managerRef,
@@ -46,6 +49,13 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
clearSessionRestoredBannerForPane(paneId)
const leafId = manager.getLeafId(paneId)
if (leafId) {
+ retireUnboundIpcTerminalPane({
+ getState: useAppStore.getState,
+ tabId,
+ leafId,
+ transport: paneTransportsRef.current.get(paneId),
+ getTransports: () => paneTransportsRef.current
+ })
useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null)
useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true })
}
@@ -79,12 +89,18 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
return
}
const transport = paneTransportsRef.current.get(paneId)
- const ptyId = transport?.getPtyId()
+ const pending = capturePendingTerminalPaneClose(controller, paneId, useAppStore.getState)
+ const ptyId = transport?.getPtyId() ?? pending?.ptyId
if (!ptyId) {
executeClosePane(paneId)
return
}
const settings = useAppStore.getState().settings
+ const close = (): void => {
+ if (!pending || pending.isCurrent()) {
+ executeClosePane(paneId)
+ }
+ }
let decided = false
const decide = (act: () => void): void => {
if (decided) {
@@ -93,12 +109,23 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
decided = true
act()
}
- const confirmClose = (): void =>
+ const confirmClose = (): void => {
+ if (pending && !pending.isCurrent()) {
+ return
+ }
+ confirmedCloseRef.current = close
setPendingCloseConfirmation({
paneId,
copyKind: getCloseDialogCopyKind(paneId)
})
- const probeTimeout = setTimeout(() => decide(confirmClose), RUNNING_CLOSE_PROBE_TIMEOUT_MS)
+ }
+ const probeTimeout = setTimeout(
+ () =>
+ decide(
+ pending && settings?.skipCloseTerminalWithRunningProcessConfirm ? close : confirmClose
+ ),
+ RUNNING_CLOSE_PROBE_TIMEOUT_MS
+ )
// Why the shared probe rather than a direct inspect: this is the same question the tab-close
// guard asks, and the two must not drift on what an unanswered host means.
void probePtyRunningWork(settings, [ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS })
@@ -106,10 +133,10 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
clearTimeout(probeTimeout)
decide(() => {
if (
- probes[0]?.verdict !== 'live' ||
+ (pending ? probes[0]?.verdict === 'exited' : probes[0]?.verdict !== 'live') ||
settings?.skipCloseTerminalWithRunningProcessConfirm
) {
- executeClosePane(paneId)
+ close()
} else {
confirmClose()
}
@@ -117,7 +144,9 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
})
.catch(() => {
clearTimeout(probeTimeout)
- decide(() => executeClosePane(paneId))
+ decide(
+ pending && !settings?.skipCloseTerminalWithRunningProcessConfirm ? confirmClose : close
+ )
})
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
@@ -143,22 +172,24 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
}, [])
const handleConfirmClose = useCallback(
(dontAskAgain: boolean) => {
- if (pendingCloseConfirmation === null) {
+ if (pendingCloseConfirmation === null || confirmedCloseRef.current === null) {
return
}
- const paneId = pendingCloseConfirmation.paneId
+ const confirmedClose = confirmedCloseRef.current
+ confirmedCloseRef.current = null
setPendingCloseConfirmation(null)
if (dontAskAgain) {
void updateSettings({
skipCloseTerminalWithRunningProcessConfirm: true
})
}
- executeClosePane(paneId)
+ confirmedClose()
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
[executeClosePane, pendingCloseConfirmation, updateSettings]
)
const handleCancelClose = useCallback(() => {
+ confirmedCloseRef.current = null
setPendingCloseConfirmation(null)
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
}, [])
diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts
index 80e67824699..e2e034fa6af 100644
--- a/src/renderer/src/store/slices/terminal-tab-retirement.ts
+++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts
@@ -135,6 +135,30 @@ export function isTerminalTabPresent(
return locateTerminalTab(state.tabsByWorktree, tabId) !== null
}
+export function hasTerminalPtyOwnerOutsidePane(
+ state: TerminalTabRetirementState,
+ identity: string,
+ tabId: string,
+ excludedLeafId?: string
+): boolean {
+ for (const [ownerTabId, owner] of collectLiveTerminalTabs(state)) {
+ const ids =
+ ownerTabId === tabId
+ ? Object.entries(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {})
+ .filter(([leafId]) => leafId !== excludedLeafId)
+ .map(([, ptyId]) => ptyId)
+ : collectPtyIdsForTab(state, ownerTabId, owner.rowPtyId)
+ if (
+ ids.some(
+ (ptyId) => getTerminalPtyOwnershipIdentity(state, ptyId, owner.worktreeId) === identity
+ )
+ ) {
+ return true
+ }
+ }
+ return false
+}
+
export function buildTerminalTabRetirementPlan(
state: TerminalTabRetirementState,
tabId: string
@@ -0,0 +1,152 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { applyPatch, parsePatch, reversePatch } from 'diff'
import { build } from 'esbuild'
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
}
const root = fileURLToPath(new URL('../../../', import.meta.url))
const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8')
const beforeSources = {}
const sourceHashes = {}
for (const parsed of parsePatch(patch)) {
const path = parsed.newFileName.replace(/^b\//, '')
const absolute = resolve(root, path)
const current = await readFile(absolute, 'utf8')
const before = applyPatch(current, reversePatch(parsed))
if (before === false) {
throw new Error(`Source changed; review the proof patch: ${path}`)
}
beforeSources[absolute.replaceAll('\\', '/')] = before
sourceHashes[path] = {
before: createHash('sha256').update(before).digest('hex'),
after: createHash('sha256').update(current).digest('hex')
}
}
for (const path of [
'src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts',
'src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts',
'src/renderer/src/components/terminal-pane/pending-split-close.test.ts',
'docs/audits/pending-split-close/daemon-proof.test.ts'
]) {
sourceHashes[path] = {
current: createHash('sha256')
.update(await readFile(resolve(root, path)))
.digest('hex')
}
}
const scratch = await mkdtemp(join(tmpdir(), 'orca-pending-split-close-'))
const require = createRequire(import.meta.url)
let runnerModuleId
try {
const runnerPath = join(scratch, 'run-process.cjs')
await build({
absWorkingDir: root,
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
outfile: runnerPath,
bundle: true,
platform: 'node',
format: 'cjs',
logLevel: 'silent'
})
runnerModuleId = require.resolve(runnerPath)
const { runProcess } = require(runnerModuleId)
const baselineConfig = join(scratch, 'before.config.mjs')
const fixedConfig = join(scratch, 'after.config.mjs')
const includes = [
'src/renderer/src/components/terminal-pane/pending-split-close.test.ts',
'docs/audits/pending-split-close/daemon-proof.test.ts'
]
const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href)
await writeFile(
baselineConfig,
`import base from ${configImport};
const beforeSources = ${JSON.stringify(beforeSources)};
export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{
name: 'pending-split-close-before-fix', enforce: 'pre',
transform(_code, id) {
const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]];
return before === undefined ? null : {code: before, map: null};
}
}]};\n`
)
await writeFile(
fixedConfig,
`import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n`
)
async function run(label, config) {
const report = join(scratch, `${label}.json`)
const result = await runProcess({
program: process.execPath,
args: [
resolve(root, 'node_modules/vitest/vitest.mjs'),
'run',
'--config',
config,
'--reporter=json',
`--outputFile=${report}`
],
cwd: root,
env: process.env,
timeoutMs: 90_000,
maxOutputBytes: 4 * 1024 * 1024
})
let parsed
try {
parsed = JSON.parse(await readFile(report, 'utf8'))
} catch (error) {
throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error })
}
return {
exitCode: result.code,
passed: parsed.numPassedTests,
failed: parsed.numFailedTests,
failedCases: parsed.testResults.flatMap((suite) =>
suite.assertionResults
.filter((test) => test.status === 'failed')
.map((test) => test.fullName)
)
}
}
const before = await run('before', baselineConfig)
const after = await run('after', fixedConfig)
const passed =
before.failed === 14 &&
before.passed === 10 &&
before.passed + before.failed === 24 &&
after.passed === 24 &&
after.failed === 0
console.log(
JSON.stringify(
{
comparison:
'Actual split close/IPC transport and temporary daemon socket tests; before reverses only fix.patch in a temporary Vite transform',
sourceHashes,
before,
after,
passed
},
null,
2
)
)
if (!passed) {
process.exitCode = 1
}
} finally {
if (runnerModuleId) {
delete require.cache[runnerModuleId]
}
await rm(scratch, { recursive: true, force: true })
}
@@ -0,0 +1,65 @@
{
"comparison": "Actual split close/IPC transport and temporary daemon socket tests; before reverses only fix.patch in a temporary Vite transform",
"sourceHashes": {
"src/renderer/src/components/terminal-pane/ipc-pty-connect.ts": {
"before": "7fe38b17d9a1c105bd93088d70086feb0c08f7f80f4513ffd975c5b4bc2167b0",
"after": "29985c0b538f701341fb8eca383386ad20f9326090dff452d4b2703b49fc4ea3"
},
"src/renderer/src/components/terminal-pane/pty-transport-types.ts": {
"before": "ab404437789ca3f7da2e8d0757778a1098bbe43dc4e04c5b0f112e7a4456c11c",
"after": "15dcd4cb02cceffca4c3b880f6b2688fbd14e6ea1421984d5873f100fa39b5fb"
},
"src/renderer/src/components/terminal-pane/pty-transport.ts": {
"before": "0455dba126457eb50a6075a8b265b29016aa5ed8c0730948e23d4f864bd8d5b3",
"after": "b5ac687bbb5c21e94a433d4a66f9b8fca46b3004f331f128c44bfa21b32a3838"
},
"src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts": {
"before": "978003ccd82d0af5dce5501edaa7c20c87170e590a5f3f06e55b28f9e4f0323f",
"after": "bbfeb2385fd120a7a1407d043011fb689b5c2b652c31150de44061f1edb76a7b"
},
"src/renderer/src/store/slices/terminal-tab-retirement.ts": {
"before": "e622afc63524826732f1779a6180d17b51ac5a2c705654062cc0907ea735db4f",
"after": "21a5f350885851398e316201686e073e3560c107b4b9d1b2e08eea6e07ec980f"
},
"src/renderer/src/components/terminal-pane/retire-unbound-ipc-terminal-pane.ts": {
"current": "3194229bdd3c992e8459cdad727a3931653953b204854486b8aaf4d129152d24"
},
"src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts": {
"current": "5fddc75178546161448f8a84b43f0e7da41c7dbb986682a09f8f7ec4e44617bf"
},
"src/renderer/src/components/terminal-pane/pending-split-close.test.ts": {
"current": "106abb33917dd403606af413558bfe95730a064199d4f7085c4970d38cfde0cc"
},
"docs/audits/pending-split-close/daemon-proof.test.ts": {
"current": "0a29132942a9f8aa60ceada3498fb4fb344c1c0e14a8c9b4cbc8ed4cfa9543d9"
}
},
"before": {
"exitCode": 1,
"passed": 10,
"failed": 14,
"failedCases": [
"explicit split close retires a real daemon cold restore whose reply is pending",
"the explicit late reply retries a kill that completed before adapter admission",
"explicit split close retires a pending reattach before and after its reply",
"explicit split close retires a pending cold-restore-new before and after its reply",
"explicit split close retires a pending ordinary-fresh before and after its reply",
"protects a same-leaf replacement that adopts after explicit close",
"protects a different-tab replacement that adopts after explicit close",
"protects a new-transport-map replacement that adopts after explicit close",
"retains explicit close intent across repeated generic destroy calls",
"preserves a different returned reattach identity",
"retries an eager provider failure when the same-ID reply arrives",
"does not invent a late session after a rejected spawn",
"routes direct SSH retirement through the existing IPC identity",
"retires a local folder-workspace split without a git worktree row"
]
},
"after": {
"exitCode": 0,
"passed": 24,
"failed": 0,
"failedCases": []
},
"passed": true
}
@@ -0,0 +1,79 @@
# SSH file readers retain unrelated streams before metadata
The file reader queued every file-stream notification while awaiting its own metadata. A delayed read therefore retained payloads from other reads that had already completed. The fix installs metadata through the mux's existing synchronous `beforeResolve` callback and ignores notifications until the read has a stream identity. Listeners still register before the request, and own frames adjacent to the response are processed correctly.
This is conditional transient retention during a pending metadata request. The proof establishes a source mechanism and its correction; it does not identify an affected host, measure a natural native I/O stall, or attribute #19831 to SSH.
## Actual producer and ownership chain
1. Desktop `filesystem-read-handlers.ts` calls the selected `SshFilesystemProvider.readFile` for `fs:readFile`; this route does not serialize reads. Runtime previews also use the provider with caller-specific caps. An AI-vault scan has an eight-operation gate, which still permits repeated completions in other slots while one operation waits.
2. `readFileViaStream` subscribes to chunk/end/error notifications before sending `fs.readFileStream`. Previously it appended all such notifications until the metadata promise's `.then` callback ran, even when they belonged to other streams.
3. Relay `FilesystemHandler` forwards the path and request context to `readRelayFileStreamMetadata`. The producer awaits `stat` before acquiring a stream slot. For unknown MIME types, its prefix probe also precedes registration. After opening/registering the file, it schedules its pump with `setImmediate` and returns metadata.
4. `RelayDispatcher` publishes the small metadata response in its control lane; the writer prioritizes control before bulk. The saturated-writer control verifies metadata precedes chunks after drain.
5. The mux runs `beforeResolve` synchronously during response dispatch, before resolving the request promise. Its decoder can dispatch adjacent notifications before any `.then` callback runs. The fix installs the stream ID and buffer at that synchronous boundary, eliminating the need to save foreign frames.
The producer, mux, dispatcher, decoder, writer, file I/O, and stream registry are actual source in the portable proof. The fixture connects both ends through an in-memory duplex transport, uses real temporary files, and supplies the filesystem handler's small path/client/pacing adapter. It does not launch an SSH process, Electron window, native PTY, or network server.
## Bounds and payload sharing
- The relay allows **16 concurrent registered streams**, with a **four-chunk ACK window** per paced stream. Chunks are 256 KiB. A metadata operation waiting before registration occupies no stream slot. Other transfers can complete and reuse slots repeatedly.
- Reader size caps are **10 MiB text / 50 MiB binary**, optionally tightened by the caller. They apply after that reader's metadata and do not charge foreign history accumulated before it.
- The metadata request has a **30,000 ms deadline**. After metadata, the reader uses a **60,000 ms inactivity deadline**, reset by its own chunks and integrated with suspend/resume. Connection disposal also releases subscriptions. These timers and transport throughput bound ordinary retention duration; suspension/event-loop stalls can delay timers. No indefinite native stall was established.
- The decoder limits each turn to 64 frames / 4 ms and bounds retained framing bytes. Those limits do not bound arrays owned by subscribers after frames are parsed.
- The mux passes the **same parsed params object** to all subscribers. Four waiting readers add four wrappers per frame, but share its payload. The result is not four copied payloads or quadratic payload-byte growth.
## Comparative results
All **80 portable cases pass**: ten controls × baseline/fixed × audited-worktree/named-main graph × Node/Electron. Node is 26.6; Electron 43.7 uses Node 24.21. Reports record exact versions, all 59 selected source hashes, the observed reader hash, and proof artifact hashes.
| Observation | Before | Fixed |
| ----------------------------------------------------------------------- | --------------------------------: | -------: |
| Four waiting readers; 16 completed 2 MiB transfers | 576 wrappers | 0 |
| Unique shared params objects retained | 144 | 0 |
| Logical base64 bytes, counted once per unique params object | 44,739,584 | 0 |
| Peak registered streams in that workload | 1 | 1 |
| ACKs processed | 128 | 128 |
| Reader history after metadata completion, handled disposal, or deadline | released | released |
| Actual pump with ACK delivery withheld | stops after 4 chunks | same |
| Sixteen active streams, then a seventeenth request | refused; later admission succeeds | same |
| Saturated writer, then drain | metadata before own chunks | same |
| Response plus own chunk/end in one decoder turn | correct result | same |
| Unpaced producer / ordinary completion | correct result | same |
| Canonical LF vs synthetic CRLF source/patch reads | 66 reads agree | same |
The primary portable workload deliberately gates four request handlers **immediately before invoking the actual relay file producer**. The request remains pending while other real transfers complete. This controlled adapter delay is distinct from a native `stat` already in progress; production source establishes that awaiting `stat` occurs at the same pre-registration phase. It does not measure how often or how long native metadata I/O delays occur on a user's machine.
The baseline observation adds only `WeakRef(pending)` to expose the closed-over array. It does not add a strong owner. Shared params identity is checked across all waiting readers. Heap deltas support the object/byte accounting but are neither exact object sizes nor RSS. After disposal, the test consumes lazy `Error.stack` and retains only error codes: externally retained unmaterialized V8 error stacks can themselves retain callback context, so the release claim is after normal error handling.
Every successful transfer checks payload length and SHA-256. Stream capacity, pacing, cancellation, and output assertions run identically for both variants. The controlled producer ignores ACK pacing in one case; this tests existing unpaced behavior, not every historical relay binary.
## Source graphs and publication
`source-versions.json` records the full 59-module import graph and five additional actual caller hashes. Both graphs select the same file-reader source variant. The audited worktree and named main `291b4ddd6f1c1af480169885e0fda7f9c78ff053` otherwise differ only in the previously published SSH writer consumed-prefix correction.
`main-context.patch` reconstructs that single context difference in memory. The loader accepts either of its two exact recorded checkout hashes and reconstructs the selected graph. This lets the same artifact run on this worktree or the independent main publication without depending on another memory PR. `fix.patch` is the separate, single-product-file change under review. Every other graph/caller source is hash-fenced; unknown production imports fail. Dedicated portable tests omit unrelated global Vitest setup files.
The current reader baseline and relay file producer are also byte-identical to `v1.4.198` (`e0826956fcfc532f5a1e55b5e081f2e57e553c43`). That named version has synchronous `beforeResolve` and control-first writer scheduling. Its comparison is limited to the recorded paths; the proof does not execute a whole historical application.
No wire field, opcode, host execution verdict, stream cap, timeout, fallback, or native process lifetime changes. Existing MethodNotFound fallback and malformed-metadata / tighter-cap / empty-image / adjacent-error handling are covered through the actual mux by the permanent regression suite.
## Reproduce
Choose either graph (`worktree` or `main`) and variant (`before` or `fixed`):
```sh
ORCA_BACKGROUND_LAUNCH=1 ORCA_SSH_READER_GRAPH=main ORCA_SSH_READER_VARIANT=fixed pnpm exec vitest run --config docs/audits/ssh-file-metadata-retention/vitest.config.mjs
```
For Electron, invoke the installed Electron binary with `ELECTRON_RUN_AS_NODE=1` and `ORCA_BACKGROUND_LAUNCH=1`, passing `node_modules/vitest/vitest.mjs` and the same arguments. Reports are separate for every graph/variant/runtime. Set `ORCA_SSH_READER_OUTPUT` to an alternative file path to preserve captured reports.
Permanent tests and the intentional baseline failure:
```sh
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/relay/fs-handler-stream.test.ts
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/ssh-file-metadata-retention/before.config.mjs src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts
```
The baseline keeps all 64 observed foreign frame objects while metadata remains pending, causing exactly the new lifetime assertion to fail; the other 22 tests pass. The initial four-suite run passed 70 tests. Detailed quality/typecheck results are in `validation.json`. Full-file casting diagnostics are the same 15 inherited assertions in the original reader and provider test, verified by exact diagnostic/source-span comparison; the changed-code gate reports no new findings. No lint rule was suppressed and no unrelated wire validation behavior was changed to satisfy that baseline cleanup.
The expanded five-suite run passes **124 tests**, including the general provider suite. The empty-file control uses the existing streaming fixture, which invokes the mux's `beforeResolve` callback before resolving metadata, and verifies all stream listeners are released. The older generic fixture omitted that callback and reproduced the CI timeout; actual-mux empty metadata controls already passed. This correction changes test setup only.
@@ -0,0 +1,20 @@
import { resolve } from 'node:path'
import { createRequire } from 'node:module'
import base from '../../../config/vitest.config.ts'
const { loadSources, versions } = createRequire(import.meta.url)('./sources.cjs')
const loaded = loadSources({ variant: 'before' })
const target = resolve(loaded.root, versions.sourcePath)
export default {
...base,
plugins: [
{
name: 'ssh-file-metadata-baseline',
enforce: 'pre',
transform(_source, id) {
return resolve(id.split('?')[0]) === target
? { code: loaded.sources.get(target), map: null }
: null
}
}
]
}
@@ -0,0 +1,138 @@
--- a/src/main/ssh/ssh-filesystem-stream-reader.ts
+++ b/src/main/ssh/ssh-filesystem-stream-reader.ts
@@ -77,7 +77 @@
- // Why: chunk/end/error frames may arrive in the same dispatch tick as the
- // metadata response. Queue them until streamIdRef is set, then drain.
- type PendingFrame =
- | { kind: 'chunk'; params: Record<string, unknown> }
- | { kind: 'end'; params: Record<string, unknown> }
- | { kind: 'error'; params: Record<string, unknown> }
- const pending: PendingFrame[] = []
+ // Install metadata during response dispatch, before adjacent stream frames.
@@ -232,13 +225,0 @@
- const drainPending = (): void => {
- while (!settled && pending.length > 0) {
- const frame = pending.shift()!
- if (frame.kind === 'chunk') {
- handleChunk(frame.params)
- } else if (frame.kind === 'end') {
- handleEnd(frame.params)
- } else {
- handleStreamError(frame.params)
- }
- }
- }
-
@@ -248 +228,0 @@
- pending.push({ kind: 'chunk', params })
@@ -257 +236,0 @@
- pending.push({ kind: 'end', params })
@@ -266 +244,0 @@
- pending.push({ kind: 'error', params })
@@ -287,51 +265,55 @@
- .request('fs.readFileStream', { filePath, flowControl: 'ack' })
- .then((rawMetadata) => {
- if (settled) {
- return
- }
- const metadata = rawMetadata as StreamMetadataResponse
- isBinary = metadata.isBinary
- isImage = metadata.isImage
- mimeType = metadata.mimeType
- resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64
-
- if (metadata.empty) {
- succeed({
- content: '',
- isBinary: metadata.isBinary,
- ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}),
- ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {})
- })
- return
- }
-
- if (typeof metadata.streamId !== 'number') {
- fail(new StreamProtocolError('Metadata missing streamId for non-empty stream'))
- return
- }
-
- const cap = sshFileStreamReadCap(metadata.isBinary, limits)
- if (metadata.totalSize < 0 || metadata.totalSize > cap) {
- streamIdRef.current = metadata.streamId
- fail(
- new FileReadCapExceededError(
- `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}`
- )
- )
- return
- }
-
- totalSize = metadata.totalSize
- totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE)
- try {
- buffer = Buffer.alloc(totalSize)
- } catch (err) {
- streamIdRef.current = metadata.streamId
- fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`))
- return
- }
- streamIdRef.current = metadata.streamId
- metadataReady = true
- inactivity.reset()
- drainPending()
- })
+ .request(
+ 'fs.readFileStream',
+ { filePath, flowControl: 'ack' },
+ {
+ beforeResolve: (rawMetadata) => {
+ if (settled) {
+ return
+ }
+ const metadata = rawMetadata as StreamMetadataResponse
+ isBinary = metadata.isBinary
+ isImage = metadata.isImage
+ mimeType = metadata.mimeType
+ resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64
+
+ if (metadata.empty) {
+ succeed({
+ content: '',
+ isBinary: metadata.isBinary,
+ ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}),
+ ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {})
+ })
+ return
+ }
+
+ if (typeof metadata.streamId !== 'number') {
+ fail(new StreamProtocolError('Metadata missing streamId for non-empty stream'))
+ return
+ }
+
+ const cap = sshFileStreamReadCap(metadata.isBinary, limits)
+ if (metadata.totalSize < 0 || metadata.totalSize > cap) {
+ streamIdRef.current = metadata.streamId
+ fail(
+ new FileReadCapExceededError(
+ `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}`
+ )
+ )
+ return
+ }
+
+ totalSize = metadata.totalSize
+ totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE)
+ try {
+ buffer = Buffer.alloc(totalSize)
+ } catch (err) {
+ streamIdRef.current = metadata.streamId
+ fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`))
+ return
+ }
+ streamIdRef.current = metadata.streamId
+ metadataReady = true
+ inactivity.reset()
+ }
+ }
+ )
@@ -0,0 +1,140 @@
{
"variant": "before",
"graph": "main",
"runtime": {
"node": "24.21.0",
"electron": "43.7.0"
},
"sources": {
"src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"observedReaderSha256": "7a5c9c14faf63197765fc5a2900e1d3488f94aaab6757425b7ef87597479e963",
"controls": [
{
"name": "held-metadata-foreign-history",
"readers": 4,
"entries": [144, 144, 144, 144],
"wrappers": 576,
"uniqueParams": 144,
"logicalBase64BytesByUniqueParams": 44739584,
"sharedAcrossReaders": true,
"decodedTransferBytes": 33554432,
"peakRegisteredStreams": 1,
"maxConcurrentStreams": 16,
"ackWindow": 4,
"ackCount": 128,
"observedHeapDelta": 45298640,
"released": true
},
{
"name": "ordinary-completion",
"passed": true
},
{
"name": "transport-disposal",
"passed": true
},
{
"name": "metadata-request-deadline",
"milliseconds": 30000,
"relayContextAborted": true,
"released": true
},
{
"name": "unpaced-relay",
"passed": true
},
{
"name": "real-pump-credit-window",
"chunksBeforeAck": 4,
"totalChunks": 6
},
{
"name": "actual-stream-capacity",
"slots": 16,
"rejectedSeventeenth": true,
"admittedAfterCompletion": true
},
{
"name": "saturated-writer-metadata-order",
"wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"]
},
{
"name": "same-turn-response-and-own-frames",
"passed": true
},
{
"name": "canonical-crlf-source-control",
"reads": 66,
"passed": true
}
],
"artifactHashes": {
"sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f",
"relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079",
"scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af",
"vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849",
"before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962",
"fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6",
"main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9",
"source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d"
}
}
@@ -0,0 +1,140 @@
{
"variant": "before",
"graph": "main",
"runtime": {
"node": "26.6.0",
"electron": null
},
"sources": {
"src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"observedReaderSha256": "7a5c9c14faf63197765fc5a2900e1d3488f94aaab6757425b7ef87597479e963",
"controls": [
{
"name": "held-metadata-foreign-history",
"readers": 4,
"entries": [144, 144, 144, 144],
"wrappers": 576,
"uniqueParams": 144,
"logicalBase64BytesByUniqueParams": 44739584,
"sharedAcrossReaders": true,
"decodedTransferBytes": 33554432,
"peakRegisteredStreams": 1,
"maxConcurrentStreams": 16,
"ackWindow": 4,
"ackCount": 128,
"observedHeapDelta": 45477336,
"released": true
},
{
"name": "ordinary-completion",
"passed": true
},
{
"name": "transport-disposal",
"passed": true
},
{
"name": "metadata-request-deadline",
"milliseconds": 30000,
"relayContextAborted": true,
"released": true
},
{
"name": "unpaced-relay",
"passed": true
},
{
"name": "real-pump-credit-window",
"chunksBeforeAck": 4,
"totalChunks": 6
},
{
"name": "actual-stream-capacity",
"slots": 16,
"rejectedSeventeenth": true,
"admittedAfterCompletion": true
},
{
"name": "saturated-writer-metadata-order",
"wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"]
},
{
"name": "same-turn-response-and-own-frames",
"passed": true
},
{
"name": "canonical-crlf-source-control",
"reads": 66,
"passed": true
}
],
"artifactHashes": {
"sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f",
"relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079",
"scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af",
"vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849",
"before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962",
"fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6",
"main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9",
"source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d"
}
}
@@ -0,0 +1,18 @@
--- a/src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts
+++ b/src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts
@@ -6 +6 @@
- entries: (T | undefined)[]
+ entries: T[]
@@ -23 +22,0 @@
- queue.entries[queue.head] = undefined
@@ -25,5 +24,2 @@
- if (
- queue.head === queue.entries.length ||
- (queue.head >= 1024 && queue.head * 2 >= queue.entries.length)
- ) {
- queue.entries = queue.entries.slice(queue.head)
+ if (queue.head === queue.entries.length) {
+ queue.entries.length = 0
@@ -36 +32 @@
- const entries = queue.entries.slice(queue.head).filter((entry): entry is T => entry !== undefined)
+ const entries = queue.entries.slice(queue.head)
@@ -0,0 +1,140 @@
{
"variant": "fixed",
"graph": "main",
"runtime": {
"node": "24.21.0",
"electron": "43.7.0"
},
"sources": {
"src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"observedReaderSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"controls": [
{
"name": "held-metadata-foreign-history",
"readers": 4,
"entries": [0, 0, 0, 0],
"wrappers": 0,
"uniqueParams": 0,
"logicalBase64BytesByUniqueParams": 0,
"sharedAcrossReaders": false,
"decodedTransferBytes": 33554432,
"peakRegisteredStreams": 1,
"maxConcurrentStreams": 16,
"ackWindow": 4,
"ackCount": 128,
"observedHeapDelta": 513692,
"released": true
},
{
"name": "ordinary-completion",
"passed": true
},
{
"name": "transport-disposal",
"passed": true
},
{
"name": "metadata-request-deadline",
"milliseconds": 30000,
"relayContextAborted": true,
"released": true
},
{
"name": "unpaced-relay",
"passed": true
},
{
"name": "real-pump-credit-window",
"chunksBeforeAck": 4,
"totalChunks": 6
},
{
"name": "actual-stream-capacity",
"slots": 16,
"rejectedSeventeenth": true,
"admittedAfterCompletion": true
},
{
"name": "saturated-writer-metadata-order",
"wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"]
},
{
"name": "same-turn-response-and-own-frames",
"passed": true
},
{
"name": "canonical-crlf-source-control",
"reads": 66,
"passed": true
}
],
"artifactHashes": {
"sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f",
"relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079",
"scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af",
"vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849",
"before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962",
"fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6",
"main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9",
"source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d"
}
}
@@ -0,0 +1,140 @@
{
"variant": "fixed",
"graph": "main",
"runtime": {
"node": "26.6.0",
"electron": null
},
"sources": {
"src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"observedReaderSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"controls": [
{
"name": "held-metadata-foreign-history",
"readers": 4,
"entries": [0, 0, 0, 0],
"wrappers": 0,
"uniqueParams": 0,
"logicalBase64BytesByUniqueParams": 0,
"sharedAcrossReaders": false,
"decodedTransferBytes": 33554432,
"peakRegisteredStreams": 1,
"maxConcurrentStreams": 16,
"ackWindow": 4,
"ackCount": 128,
"observedHeapDelta": -669752,
"released": true
},
{
"name": "ordinary-completion",
"passed": true
},
{
"name": "transport-disposal",
"passed": true
},
{
"name": "metadata-request-deadline",
"milliseconds": 30000,
"relayContextAborted": true,
"released": true
},
{
"name": "unpaced-relay",
"passed": true
},
{
"name": "real-pump-credit-window",
"chunksBeforeAck": 4,
"totalChunks": 6
},
{
"name": "actual-stream-capacity",
"slots": 16,
"rejectedSeventeenth": true,
"admittedAfterCompletion": true
},
{
"name": "saturated-writer-metadata-order",
"wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"]
},
{
"name": "same-turn-response-and-own-frames",
"passed": true
},
{
"name": "canonical-crlf-source-control",
"reads": 66,
"passed": true
}
],
"artifactHashes": {
"sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f",
"relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079",
"scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af",
"vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849",
"before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962",
"fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6",
"main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9",
"source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d"
}
}
@@ -0,0 +1,226 @@
import { afterEach, beforeEach, expect, vi } from 'vitest'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createHash, randomBytes } from 'node:crypto'
import { writeFileSync, readFileSync } from 'node:fs'
import { SshChannelMultiplexer } from '../../../src/main/ssh/ssh-channel-multiplexer'
import { readFileViaStream } from '../../../src/main/ssh/ssh-filesystem-stream-reader'
import { RelayDispatcher } from '../../../src/relay/dispatcher'
import { RelayStreamRegistry } from '../../../src/relay/fs-stream-registry'
import { readRelayFileStreamMetadata } from '../../../src/relay/fs-handler-file-read'
import { createRequire } from 'node:module'
const { loadSources } = createRequire(import.meta.url)('./sources.cjs')
const sourceInfo = loadSources()
const gates = new Map()
const candidate = process.env.ORCA_SSH_READER_VARIANT !== 'before'
const graph = process.env.ORCA_SSH_READER_GRAPH ?? 'worktree'
const artifactNames = [
'sources.cjs',
'relay-fixture.mjs',
'scenario.test.mjs',
'vitest.config.mjs',
'before.config.mjs',
'fix.patch',
'main-context.patch',
'source-versions.json'
]
const report = {
variant: candidate ? 'fixed' : 'before',
graph,
runtime: { node: process.versions.node, electron: process.versions.electron ?? null },
sources: sourceInfo.hashes,
observedReaderSha256: sourceInfo.observedReaderSha256,
controls: []
}
const nextTurn = () => new Promise((resolve) => setImmediate(resolve))
let directory
let fixtures
let heldPaths
function gate(path) {
let release
const promise = new Promise((resolve) => {
release = resolve
})
gates.set(path, { promise, release })
}
async function heldFiles(count) {
const paths = []
for (let i = 0; i < count; i++) {
const path = join(directory, `held-${i}.png`)
await writeFile(path, '')
gate(path)
paths.push(path)
heldPaths.push(path)
}
return paths
}
function connect({ pacing = true, passAcks = true, blockFirstWrite = false } = {}) {
let receive
let drain
let blocked = blockFirstWrite
const registry = new RelayStreamRegistry()
const stats = { peakStreams: 0, chunks: 0, ends: 0, acks: 0, contexts: [], wireOrder: [] }
const dispatcher = new RelayDispatcher(
(data) => {
if (data[0] === 1) {
const message = JSON.parse(data.subarray(13).toString())
stats.wireOrder.push(message.method ?? 'response')
}
receive(data)
if (blocked) {
blocked = false
return false
}
},
{
waitWriteDrain(callback) {
drain = callback
return () => {}
}
}
)
const mux = new SshChannelMultiplexer({
write(data) {
dispatcher.feed(data)
},
onData(callback) {
receive = callback
},
onClose() {}
})
dispatcher.onRequest('fs.readFileStream', async (params, context) => {
stats.contexts.push(context)
await gates.get(params.filePath)?.promise
const result = await readRelayFileStreamMetadata(
params.filePath,
dispatcher,
registry,
context,
{ clientId: context.clientId, paceWithAcks: pacing && params.flowControl === 'ack' }
)
stats.peakStreams = Math.max(stats.peakStreams, registry.size())
return result
})
dispatcher.onNotification('fs.streamAck', (params) => {
stats.acks++
if (passAcks) {
registry.recordAck(params.streamId, params.seq)
}
})
dispatcher.onNotification('fs.cancelStream', (params) => registry.abort(params.streamId))
mux.onNotificationByMethod('fs.streamChunk', () => {
stats.chunks++
})
mux.onNotificationByMethod('fs.streamEnd', () => {
stats.ends++
})
const fixture = {
mux,
dispatcher,
registry,
stats,
drain() {
drain?.()
}
}
fixtures.push(fixture)
return fixture
}
function snapshot(paths) {
const rows = paths.map((path) => globalThis.__sshPendingReaders.get(path)?.deref() ?? [])
const unique = new Set(rows.flatMap((row) => row.map((frame) => frame.params)))
const bytes = [...unique].reduce(
(sum, params) => sum + (typeof params.data === 'string' ? params.data.length : 0),
0
)
return {
readers: paths.length,
entries: rows.map((row) => row.length),
wrappers: rows.reduce((sum, row) => sum + row.length, 0),
uniqueParams: unique.size,
logicalBase64BytesByUniqueParams: bytes,
sharedAcrossReaders:
rows.length > 1 &&
rows[0].length > 0 &&
rows.every((row) => row.every((frame, index) => frame.params === rows[0][index]?.params))
}
}
async function collect() {
for (let i = 0; i < 5; i++) {
await nextTurn()
global.gc()
}
await nextTurn()
}
async function assertReleased(paths) {
await collect()
for (const path of paths) {
expect(globalThis.__sshPendingReaders.get(path)?.deref()).toBeUndefined()
}
}
async function makePayload(size) {
const path = join(directory, 'payload.png')
const bytes = randomBytes(size)
await writeFile(path, bytes)
return { path, hash: createHash('sha256').update(bytes).digest('hex'), size }
}
async function successfulRead(mux, payload) {
const result = await readFileViaStream(mux, payload.path)
expect(result.isImage).toBe(true)
const bytes = Buffer.from(result.content, 'base64')
expect(bytes.length).toBe(payload.size)
expect(createHash('sha256').update(bytes).digest('hex')).toBe(payload.hash)
}
beforeEach(async () => {
expect(process.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
directory = await mkdtemp(join(tmpdir(), 'orca-ssh-reader-'))
fixtures = []
heldPaths = []
globalThis.__sshPendingReaders = new Map()
})
afterEach(async () => {
vi.useRealTimers()
for (const entry of gates.values()) {
entry.release()
}
gates.clear()
for (const fixture of fixtures) {
fixture.mux.dispose()
fixture.dispatcher.dispose()
await fixture.registry.disposeAll()
}
await nextTurn()
await rm(directory, { recursive: true, force: true })
report.artifactHashes = Object.fromEntries(
artifactNames.map((name) => [
name,
createHash('sha256')
.update(readFileSync(new URL(name, import.meta.url)))
.digest('hex')
])
)
writeFileSync(
process.env.ORCA_SSH_READER_OUTPUT ??
new URL(
`./${graph}-${report.variant}-${process.versions.electron ? 'electron' : 'node'}-results.json`,
import.meta.url
),
`${JSON.stringify(report, null, 2)}\n`
)
})
export {
candidate,
report,
nextTurn,
gates,
heldFiles,
connect,
snapshot,
collect,
assertReleased,
makePayload,
successfulRead
}
@@ -0,0 +1,267 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { describe, expect, it, vi } from 'vitest'
import { SshChannelMultiplexer } from '../../../src/main/ssh/ssh-channel-multiplexer'
import { readFileViaStream } from '../../../src/main/ssh/ssh-filesystem-stream-reader'
import {
encodeJsonRpcFrame,
MAX_CONCURRENT_STREAMS,
STREAM_ACK_WINDOW_CHUNKS,
STREAM_CHUNK_SIZE,
RelayErrorCode
} from '../../../src/relay/protocol'
import {
candidate,
report,
nextTurn,
gates,
heldFiles,
connect,
snapshot,
collect,
assertReleased,
makePayload,
successfulRead
} from './relay-fixture.mjs'
describe('actual SSH mux, relay dispatcher and file producer ownership', () => {
it('retains shared foreign frames while four metadata request handlers are deliberately held', async () => {
const paths = await heldFiles(4)
const { mux, stats } = connect()
const pending = paths.map((path) => readFileViaStream(mux, path))
const payload = await makePayload(2 * 1024 * 1024)
await collect()
const startHeap = process.memoryUsage().heapUsed
for (let i = 0; i < 16; i++) {
await successfulRead(mux, payload)
}
await collect()
const retained = snapshot(paths)
expect(stats.chunks).toBe(128)
expect(stats.ends).toBe(16)
expect(stats.acks).toBe(128)
expect(stats.peakStreams).toBe(1)
expect(retained.entries).toEqual(Array(4).fill(candidate ? 0 : 144))
expect(retained.uniqueParams).toBe(candidate ? 0 : 144)
expect(retained.logicalBase64BytesByUniqueParams).toBe(
candidate ? 0 : 128 * Math.ceil(STREAM_CHUNK_SIZE / 3) * 4
)
expect(retained.sharedAcrossReaders).toBe(!candidate)
const heapDelta = process.memoryUsage().heapUsed - startHeap
for (const path of paths) {
gates.get(path).release()
}
expect(await Promise.all(pending)).toEqual(
Array.from({ length: 4 }, () => ({
content: '',
isBinary: true,
isImage: true,
mimeType: 'image/png'
}))
)
await assertReleased(paths)
report.controls.push({
name: 'held-metadata-foreign-history',
...retained,
decodedTransferBytes: 16 * payload.size,
peakRegisteredStreams: stats.peakStreams,
maxConcurrentStreams: MAX_CONCURRENT_STREAMS,
ackWindow: STREAM_ACK_WINDOW_CHUNKS,
ackCount: stats.acks,
observedHeapDelta: heapDelta,
released: true
})
})
it('finishes normally without a held metadata request and releases reader state', async () => {
const { mux } = connect()
const payload = await makePayload(STREAM_CHUNK_SIZE + 17)
for (let i = 0; i < 3; i++) {
await successfulRead(mux, payload)
}
await assertReleased([payload.path])
report.controls.push({ name: 'ordinary-completion', passed: true })
})
it('cleans up all pending metadata listeners when transport is disposed', async () => {
const paths = await heldFiles(2)
const { mux } = connect()
const pending = paths.map((path) =>
readFileViaStream(mux, path).catch((error) => {
void error.stack
return error.code
})
)
await successfulRead(mux, await makePayload(STREAM_CHUNK_SIZE + 1))
expect(snapshot(paths).wrappers).toBe(candidate ? 0 : 6)
mux.dispose('connection_lost')
const results = await Promise.all(pending)
expect(results).toEqual(['CONNECTION_LOST', 'CONNECTION_LOST'])
await assertReleased(paths)
report.controls.push({ name: 'transport-disposal', passed: true })
})
it('retains no reader history after the 30 second request deadline while relay work remains pending', async () => {
const paths = await heldFiles(1)
vi.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date']
})
const { mux, stats } = connect()
const pending = readFileViaStream(mux, paths[0]).catch((error) => error)
await successfulRead(mux, await makePayload(STREAM_CHUNK_SIZE))
expect(snapshot(paths).wrappers).toBe(candidate ? 0 : 2)
// Keep the actual mux/relay health timers active on the fake clock as well.
for (let i = 0; i < 6; i++) {
await vi.advanceTimersByTimeAsync(5000)
}
expect((await pending).code).toBe('SSH_MUX_REQUEST_TIMEOUT')
expect(stats.contexts[0].signal.aborted).toBe(true)
vi.useRealTimers()
await assertReleased(paths)
report.controls.push({
name: 'metadata-request-deadline',
milliseconds: 30000,
relayContextAborted: true,
released: true
})
})
it('supports a relay that ignores optional chunk pacing', async () => {
const paths = await heldFiles(1)
const { mux, stats } = connect({ pacing: false })
const pending = readFileViaStream(mux, paths[0])
await successfulRead(mux, await makePayload(2 * STREAM_CHUNK_SIZE + 7))
expect(stats.chunks).toBe(3)
expect(snapshot(paths).wrappers).toBe(candidate ? 0 : 4)
gates.get(paths[0]).release()
await pending
await assertReleased(paths)
report.controls.push({ name: 'unpaced-relay', passed: true })
})
it('actually stops the pump after four chunks until acknowledgements resume', async () => {
const { mux, registry, stats } = connect({ passAcks: false })
const payload = await makePayload(6 * STREAM_CHUNK_SIZE)
const pending = successfulRead(mux, payload)
for (let i = 0; i < 200 && stats.chunks < 4; i++) {
await new Promise((resolve) => setTimeout(resolve, 2))
}
expect(stats.chunks).toBe(4)
await new Promise((resolve) => setTimeout(resolve, 25))
expect(stats.chunks).toBe(4)
registry.recordAck(1, 3)
await pending
expect(stats.chunks).toBe(6)
report.controls.push({ name: 'real-pump-credit-window', chunksBeforeAck: 4, totalChunks: 6 })
})
it('enforces the real 16 slot limit and admits another file after completion', async () => {
const { mux, registry, stats } = connect({ passAcks: false })
const payload = await makePayload(5 * STREAM_CHUNK_SIZE)
const pending = Array.from({ length: 16 }, () => successfulRead(mux, payload))
for (let i = 0; i < 500 && stats.chunks < 64; i++) {
await new Promise((resolve) => setTimeout(resolve, 2))
}
expect(registry.size()).toBe(16)
expect(stats.chunks).toBe(64)
const error = await readFileViaStream(mux, payload.path).catch((error) => error)
expect(error.code).toBe(RelayErrorCode.TooManyStreams)
for (let id = 1; id <= 16; id++) {
registry.recordAck(id, 3)
}
await Promise.all(pending)
expect(registry.size()).toBe(0)
const small = await makePayload(1)
await successfulRead(mux, small)
report.controls.push({
name: 'actual-stream-capacity',
slots: 16,
rejectedSeventeenth: true,
admittedAfterCompletion: true
})
})
it('writes metadata before own chunks when the relay writer resumes from saturation', async () => {
const fixture = connect({ blockFirstWrite: true })
fixture.dispatcher.notifyClient(1, 'probe.prime')
const payload = await makePayload(STREAM_CHUNK_SIZE + 1)
const pending = successfulRead(fixture.mux, payload)
for (let i = 0; i < 100 && fixture.stats.peakStreams < 1; i++) {
await new Promise((resolve) => setTimeout(resolve, 2))
}
await nextTurn()
expect(fixture.stats.peakStreams).toBe(1)
expect(fixture.stats.wireOrder).toEqual(['probe.prime'])
fixture.drain()
await pending
expect(fixture.stats.wireOrder).toEqual([
'probe.prime',
'response',
'fs.streamChunk',
'fs.streamChunk',
'fs.streamEnd'
])
report.controls.push({
name: 'saturated-writer-metadata-order',
wireOrder: fixture.stats.wireOrder
})
})
it('handles response and own chunk/end in one decoder dispatch turn', async () => {
let receive
let requestId
const mux = new SshChannelMultiplexer({
write(data) {
if (data[0] === 1) {
const message = JSON.parse(data.subarray(13).toString())
if (message.method === 'fs.readFileStream') {
requestId = message.id
}
}
},
onData(callback) {
receive = callback
},
onClose() {}
})
const pending = readFileViaStream(mux, 'coalesced.png')
const data = Buffer.from('adjacent\0frame')
receive(
Buffer.concat([
encodeJsonRpcFrame(
{
jsonrpc: '2.0',
id: requestId,
result: { streamId: 7, totalSize: data.length, isBinary: true }
},
1,
0
),
encodeJsonRpcFrame(
{
jsonrpc: '2.0',
method: 'fs.streamChunk',
params: { streamId: 7, seq: 0, data: data.toString('base64') }
},
2,
0
),
encodeJsonRpcFrame(
{ jsonrpc: '2.0', method: 'fs.streamEnd', params: { streamId: 7 } },
3,
0
)
])
)
expect(await pending).toEqual({ content: data.toString('base64'), isBinary: true })
mux.dispose()
await assertReleased(['coalesced.png'])
report.controls.push({ name: 'same-turn-response-and-own-frames', passed: true })
})
})
it('reconstructs both sources identically from synthetic CRLF checkout and patch reads', () => {
const { loadSources } = createRequire(import.meta.url)('./sources.cjs')
let reads = 0
const observed = loadSources({
read(filename) {
reads += 1
return readFileSync(filename, 'utf8').replace(/\r?\n/g, '\r\n')
}
})
const ordinary = loadSources()
expect(observed.hashes).toEqual(ordinary.hashes)
expect([...observed.sources]).toEqual([...ordinary.sources])
report.controls.push({ name: 'canonical-crlf-source-control', reads, passed: true })
})
@@ -0,0 +1,225 @@
{
"sourcePath": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"baselineSha256": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef",
"fixedSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"contextPath": "src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts",
"worktreeGraph": {
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"mainGraph": {
"src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"callerHashes": {
"src/main/providers/ssh-filesystem-provider.ts": "de6051f43ea272fe0a15b7ef5c6df5c8caedfa7adaada205386c4f19be7f04f2",
"src/main/ipc/filesystem/filesystem-read-handlers.ts": "cf012e6a7049f803936c75a32619cf1f209053d4b8951f67f7e4e39218448a6a",
"src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts": "27c4b20397471fe036f8fdfe0f76f089bf6bbf9e6d4ae9f23b41bc49359bc8bc",
"src/main/ai-vault/remote-session-scan-concurrency.ts": "1b7147a2b5d793d7f78059c2ae67c41f531ebfa37a9bd292091401d21143e36a",
"src/relay/fs-handler.ts": "bc8c57bdf91e5d34b2fb42d8fd00260873224c7b04d69e5aafae149a377c4235"
},
"mainRef": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
"mainOriginalSourceHashes": {
"src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "3e5fe7a1e3537505baf42869449193fc0448339d851254a49527ec2a1c7cbb50",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"reportedVersionComparison": {
"commit": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
"sourceHashes": {
"src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef",
"src/main/ssh/ssh-channel-multiplexer.ts": "480c722b27dd1ffb8c70bfca8fb3568294ff2777b7b02607548df93bb280f6ae",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "4a73e2194f15ec0604802fe6810742f34930fc55e542458b6d8ab7344ee841a2",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/providers/ssh-filesystem-provider.ts": "03d53c8be02737024f5c5f4f08d614a276a8d03f0e599c331be9d0eaa85c2d80",
"src/main/ipc/filesystem/filesystem-read-handlers.ts": "2491d39f0576a961a249ebbf94e563f2eefe7a0cf899bbb6bc77b7a9e7a2aa30",
"src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts": "27c4b20397471fe036f8fdfe0f76f089bf6bbf9e6d4ae9f23b41bc49359bc8bc",
"src/main/ai-vault/remote-session-scan-concurrency.ts": "175944c836a683a41c7d6446d45794eb9e0dd021414926370c64bc5fd574ad34",
"src/relay/fs-handler.ts": "b4f4121c3081b8c98749c4d1cb45fd3355f0c053c9cc078f539cb71210976533",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/relay/protocol.ts": "678f9d6dac998385ca10e94da9864fe3451c82fcc88b9c28490dfb42e99606a4",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34"
}
}
}
@@ -0,0 +1,84 @@
const assert = require('node:assert/strict')
const { readFileSync } = require('node:fs')
const { createHash } = require('node:crypto')
const path = require('node:path')
const { applyPatch, parsePatch, reversePatch } = require('diff')
const root = path.resolve(__dirname, '../../..')
const canonicalLf = (text) => text.replaceAll('\r\n', '\n')
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
const readText = (filename) => canonicalLf(readFileSync(filename, 'utf8'))
const versions = JSON.parse(readText(path.join(__dirname, 'source-versions.json')))
function checkedPatch(name, expectedPath, read) {
const patches = parsePatch(canonicalLf(read(path.join(__dirname, name))))
assert.equal(patches.length, 1)
assert.equal(patches[0].newFileName, `b/${expectedPath}`)
assert.equal(patches[0].oldFileName, `a/${expectedPath}`)
return patches[0]
}
function observePending(source) {
return source.replace(
' const pending: PendingFrame[] = []',
' const pending: PendingFrame[] = []; globalThis.__sshPendingReaders.set(filePath, new WeakRef(pending))'
)
}
function loadSources({
graph = process.env.ORCA_SSH_READER_GRAPH ?? 'worktree',
variant = process.env.ORCA_SSH_READER_VARIANT ?? 'fixed',
read = readText
} = {}) {
assert.ok(graph === 'worktree' || graph === 'main')
assert.ok(variant === 'before' || variant === 'fixed')
const targetPatch = checkedPatch('fix.patch', versions.sourcePath, read)
const contextPatch = checkedPatch('main-context.patch', versions.contextPath, read)
const sources = new Map()
const hashes = {}
const selected = graph === 'main' ? versions.mainGraph : versions.worktreeGraph
for (const [relative, expected] of Object.entries(selected)) {
const filename = path.join(root, relative)
let text = canonicalLf(read(filename))
if (relative === versions.sourcePath) {
assert.equal(sha256(text), versions.fixedSha256, 'Fixed reader drift')
if (variant === 'before') {
text = applyPatch(text, reversePatch(targetPatch))
assert.notEqual(text, false, 'Reader patch no longer reverses')
assert.equal(sha256(text), versions.baselineSha256)
}
} else if (relative === versions.contextPath) {
const actual = sha256(text)
assert.ok(
actual === versions.worktreeGraph[relative] || actual === versions.mainGraph[relative],
'Unaudited writer context'
)
if (actual !== expected) {
text = applyPatch(text, graph === 'main' ? contextPatch : reversePatch(contextPatch))
assert.notEqual(text, false, 'Writer context no longer reconstructs')
}
assert.equal(sha256(text), expected)
} else {
assert.equal(sha256(text), expected, `Graph source drift: ${relative}`)
}
hashes[relative] = sha256(text)
sources.set(filename, text)
}
for (const [relative, expected] of Object.entries(versions.callerHashes)) {
assert.equal(
sha256(canonicalLf(read(path.join(root, relative)))),
expected,
`Caller drift: ${relative}`
)
}
const reader = sources.get(path.join(root, versions.sourcePath))
return {
root,
sources,
hashes,
observedReaderSha256: sha256(observePending(reader)),
graph,
variant
}
}
module.exports = { loadSources, observePending, readText, versions }
@@ -0,0 +1,264 @@
{
"productTests": {
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/providers/ssh-filesystem-provider.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/relay/fs-handler-stream.test.ts",
"passed": 124,
"files": 5,
"newTests": 9,
"exitCode": 0
},
"baselineOverlay": {
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/ssh-file-metadata-retention/before.config.mjs src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts",
"passed": 22,
"expectedFailed": 1,
"failure": "64 foreign frame params objects remain reachable before this reader receives metadata.",
"exitCode": 1
},
"transportIntegration": {
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/relay/fs-stream-pty-echo-backpressure.integration.test.ts",
"passed": 3,
"exitCode": 0
},
"typecheck": {
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm tc:node",
"exitCode": 0
},
"portableProof": {
"cases": 80,
"reports": [
{
"file": "worktree-before-node-results.json",
"controls": 10,
"runtime": {
"node": "26.6.0",
"electron": null
},
"heapDelta": 45478552
},
{
"file": "worktree-before-electron-results.json",
"controls": 10,
"runtime": {
"node": "24.21.0",
"electron": "43.7.0"
},
"heapDelta": 45298880
},
{
"file": "worktree-fixed-node-results.json",
"controls": 10,
"runtime": {
"node": "26.6.0",
"electron": null
},
"heapDelta": -663256
},
{
"file": "worktree-fixed-electron-results.json",
"controls": 10,
"runtime": {
"node": "24.21.0",
"electron": "43.7.0"
},
"heapDelta": 520252
},
{
"file": "main-before-node-results.json",
"controls": 10,
"runtime": {
"node": "26.6.0",
"electron": null
},
"heapDelta": 45477336
},
{
"file": "main-before-electron-results.json",
"controls": 10,
"runtime": {
"node": "24.21.0",
"electron": "43.7.0"
},
"heapDelta": 45298640
},
{
"file": "main-fixed-node-results.json",
"controls": 10,
"runtime": {
"node": "26.6.0",
"electron": null
},
"heapDelta": -669752
},
{
"file": "main-fixed-electron-results.json",
"controls": 10,
"runtime": {
"node": "24.21.0",
"electron": "43.7.0"
},
"heapDelta": 513692
}
],
"sourceGraphModules": 59,
"additionalCallerSources": 5,
"canonicalCrLfReads": 66
},
"quality": [
{
"label": "ordinary lint",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs",
"exitCode": 0
},
{
"label": "casting",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings --config config/oxlint-code-quality-casting.json src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs",
"exitCode": 1
},
{
"label": "type-aware",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings --type-aware --config config/oxlint-code-quality-type-aware.json src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs",
"exitCode": 0
},
{
"label": "native code quality",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings --config config/oxlint-code-quality-native-plugins.json src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs",
"exitCode": 0
},
{
"label": "anti-slop",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec oxlint --no-ignore --deny-warnings --config config/oxlint-anti-slop.json src/main/ssh/ssh-filesystem-stream-reader.ts src/main/ssh/ssh-filesystem-stream-retention.test.ts src/main/providers/ssh-filesystem-provider-stream.test.ts docs/audits/ssh-file-metadata-retention/sources.cjs docs/audits/ssh-file-metadata-retention/relay-fixture.mjs docs/audits/ssh-file-metadata-retention/scenario.test.mjs docs/audits/ssh-file-metadata-retention/vitest.config.mjs docs/audits/ssh-file-metadata-retention/before.config.mjs",
"exitCode": 0
}
],
"inheritedCasting": {
"baselineFindings": 15,
"currentFindings": 15,
"sameRuleAndExactAssertionSpans": true,
"findings": [
{
"path": "src/main/providers/ssh-filesystem-provider-stream.test.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["mux as never"]
},
{
"path": "src/main/providers/ssh-filesystem-provider-stream.test.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["new Error('Method not found') as Error & { code: number }"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["err as Error"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["err as Error"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["err as { code?: unknown }"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["new Error(message) as Error & { code: string }"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["new Error(message) as Error & { code: string }"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["params.code as string | undefined"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["params.data as string"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["params.message as string | undefined"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["params.seq as number"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["params.streamId as number | undefined"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["params.streamId as number | undefined"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["params.streamId as number | undefined"]
},
{
"path": "src/main/ssh/ssh-filesystem-stream-reader.ts",
"rule": "typescript(consistent-type-assertions)",
"spans": ["rawMetadata as StreamMetadataResponse"]
}
],
"baselineSourceHashes": {
"src/main/providers/ssh-filesystem-provider-stream.test.ts": "422bcaa7293925217f9c61197599f26e9f0f0993cb562ec004a48a1b27d43fa3",
"src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef"
}
},
"changedCodeGate": {
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm run check:code-quality:changed",
"exitCode": 0,
"newFindings": 0,
"observedGlobalChangedFiles": 392,
"baseline": "2fccacadbe23",
"scope": "Primary worktree including concurrent global evidence changes"
},
"whitespace": {
"fullContentsChecked": true,
"zeroContextPatches": true,
"diagnostics": 0
},
"formatting": {
"idempotent": true
},
"emptyFileFixtureCiCorrection": {
"failedHead": "458e11e9f3f5981a85fc1006083c19738a8b26e3",
"jobUrl": "https://github.com/stablyai/orca/actions/runs/35186075669/job/105088389138",
"cause": "General provider test double resolved empty metadata without invoking the synchronous beforeResolve callback. Real-mux empty metadata cases already passed.",
"correction": "Move the existing empty-file control to the existing streaming fixture, which models beforeResolve; also assert all stream/disposal listeners are released. No product change.",
"localCounterexample": {
"failed": 1,
"skipped": 53,
"timeoutMs": 1000,
"logSha256": "a53af8ea05b06fabf918ea12f5c81f635dbfec97709688ce1139f9efc619a438"
},
"fixedFiveSuites": {
"passed": 124,
"exitCode": 0,
"logSha256": "bab1beb5ed50ff0611b67eda8a96b1176a86442baa50abc07516402e987e31db"
},
"baselineOverlay": {
"expectedFailed": 1,
"passed": 22,
"exitCode": 1,
"logSha256": "044aaae5fd3943263616d32eafafbfbb0fc4fb742f754feb233a8e6b49160b6b"
},
"otherCiFailure": {
"jobUrl": "https://github.com/stablyai/orca/actions/runs/35186075669/job/105088388326",
"path": "src/main/windows/windows-pty-job.win32.test.ts",
"failure": "ConPTY job ownership: grandchild never reported its pid",
"mainAndPublishedBlob": "c5f408f73115a11821b06fafe04ca28eaa15acb7",
"scope": "Untouched Windows native test; missing PID cause not established. No retry or timing-threshold change."
}
}
}
@@ -0,0 +1,31 @@
import { resolve, sep } from 'node:path'
import { createRequire } from 'node:module'
import base from '../../../config/vitest.config.ts'
const { loadSources, observePending } = createRequire(import.meta.url)('./sources.cjs')
const loaded = loadSources()
export default {
...base,
test: {
...base.test,
setupFiles: [],
include: ['docs/audits/ssh-file-metadata-retention/scenario.test.mjs'],
maxWorkers: 1
},
plugins: [
{
name: 'ssh-file-metadata-source-graph',
enforce: 'pre',
transform(_source, id) {
const absolute = resolve(id.split('?')[0])
const source = loaded.sources.get(absolute)
if (source !== undefined) {
return { code: observePending(source), map: null }
}
if (absolute.startsWith(resolve(loaded.root, 'src') + sep) && absolute.endsWith('.ts')) {
throw new Error(`Unreviewed source import: ${absolute}`)
}
return null
}
}
]
}
@@ -0,0 +1,140 @@
{
"variant": "before",
"graph": "worktree",
"runtime": {
"node": "24.21.0",
"electron": "43.7.0"
},
"sources": {
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"observedReaderSha256": "7a5c9c14faf63197765fc5a2900e1d3488f94aaab6757425b7ef87597479e963",
"controls": [
{
"name": "held-metadata-foreign-history",
"readers": 4,
"entries": [144, 144, 144, 144],
"wrappers": 576,
"uniqueParams": 144,
"logicalBase64BytesByUniqueParams": 44739584,
"sharedAcrossReaders": true,
"decodedTransferBytes": 33554432,
"peakRegisteredStreams": 1,
"maxConcurrentStreams": 16,
"ackWindow": 4,
"ackCount": 128,
"observedHeapDelta": 45298880,
"released": true
},
{
"name": "ordinary-completion",
"passed": true
},
{
"name": "transport-disposal",
"passed": true
},
{
"name": "metadata-request-deadline",
"milliseconds": 30000,
"relayContextAborted": true,
"released": true
},
{
"name": "unpaced-relay",
"passed": true
},
{
"name": "real-pump-credit-window",
"chunksBeforeAck": 4,
"totalChunks": 6
},
{
"name": "actual-stream-capacity",
"slots": 16,
"rejectedSeventeenth": true,
"admittedAfterCompletion": true
},
{
"name": "saturated-writer-metadata-order",
"wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"]
},
{
"name": "same-turn-response-and-own-frames",
"passed": true
},
{
"name": "canonical-crlf-source-control",
"reads": 66,
"passed": true
}
],
"artifactHashes": {
"sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f",
"relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079",
"scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af",
"vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849",
"before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962",
"fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6",
"main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9",
"source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d"
}
}
@@ -0,0 +1,140 @@
{
"variant": "before",
"graph": "worktree",
"runtime": {
"node": "26.6.0",
"electron": null
},
"sources": {
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/main/ssh/ssh-filesystem-stream-reader.ts": "b8586de1412df98428939a0a4870c5f8ccab22f506a188b2eb1c7a38c317b1ef",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"observedReaderSha256": "7a5c9c14faf63197765fc5a2900e1d3488f94aaab6757425b7ef87597479e963",
"controls": [
{
"name": "held-metadata-foreign-history",
"readers": 4,
"entries": [144, 144, 144, 144],
"wrappers": 576,
"uniqueParams": 144,
"logicalBase64BytesByUniqueParams": 44739584,
"sharedAcrossReaders": true,
"decodedTransferBytes": 33554432,
"peakRegisteredStreams": 1,
"maxConcurrentStreams": 16,
"ackWindow": 4,
"ackCount": 128,
"observedHeapDelta": 45478552,
"released": true
},
{
"name": "ordinary-completion",
"passed": true
},
{
"name": "transport-disposal",
"passed": true
},
{
"name": "metadata-request-deadline",
"milliseconds": 30000,
"relayContextAborted": true,
"released": true
},
{
"name": "unpaced-relay",
"passed": true
},
{
"name": "real-pump-credit-window",
"chunksBeforeAck": 4,
"totalChunks": 6
},
{
"name": "actual-stream-capacity",
"slots": 16,
"rejectedSeventeenth": true,
"admittedAfterCompletion": true
},
{
"name": "saturated-writer-metadata-order",
"wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"]
},
{
"name": "same-turn-response-and-own-frames",
"passed": true
},
{
"name": "canonical-crlf-source-control",
"reads": 66,
"passed": true
}
],
"artifactHashes": {
"sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f",
"relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079",
"scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af",
"vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849",
"before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962",
"fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6",
"main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9",
"source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d"
}
}
@@ -0,0 +1,140 @@
{
"variant": "fixed",
"graph": "worktree",
"runtime": {
"node": "24.21.0",
"electron": "43.7.0"
},
"sources": {
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"observedReaderSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"controls": [
{
"name": "held-metadata-foreign-history",
"readers": 4,
"entries": [0, 0, 0, 0],
"wrappers": 0,
"uniqueParams": 0,
"logicalBase64BytesByUniqueParams": 0,
"sharedAcrossReaders": false,
"decodedTransferBytes": 33554432,
"peakRegisteredStreams": 1,
"maxConcurrentStreams": 16,
"ackWindow": 4,
"ackCount": 128,
"observedHeapDelta": 520252,
"released": true
},
{
"name": "ordinary-completion",
"passed": true
},
{
"name": "transport-disposal",
"passed": true
},
{
"name": "metadata-request-deadline",
"milliseconds": 30000,
"relayContextAborted": true,
"released": true
},
{
"name": "unpaced-relay",
"passed": true
},
{
"name": "real-pump-credit-window",
"chunksBeforeAck": 4,
"totalChunks": 6
},
{
"name": "actual-stream-capacity",
"slots": 16,
"rejectedSeventeenth": true,
"admittedAfterCompletion": true
},
{
"name": "saturated-writer-metadata-order",
"wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"]
},
{
"name": "same-turn-response-and-own-frames",
"passed": true
},
{
"name": "canonical-crlf-source-control",
"reads": 66,
"passed": true
}
],
"artifactHashes": {
"sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f",
"relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079",
"scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af",
"vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849",
"before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962",
"fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6",
"main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9",
"source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d"
}
}
@@ -0,0 +1,140 @@
{
"variant": "fixed",
"graph": "worktree",
"runtime": {
"node": "26.6.0",
"electron": null
},
"sources": {
"src/relay/dispatcher.ts": "6dca32ec33e410fa9226d6c0a4a548ed06fc5bbf1a65ccdbcae7cc43a9328d18",
"src/main/ssh/ssh-channel-multiplexer.ts": "9ecd88963fec72901596bd25d2cb4c8666ef41d3c8a968242c6eef52095ee108",
"src/main/ssh/ssh-filesystem-stream-reader.ts": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"src/relay/fs-handler-file-read.ts": "2d70577839cb150ad0977981d54904b1a25bd8b5de7cfe19e018c08c36f41cd2",
"src/relay/protocol.ts": "faebaded7e8c8b98f021c791b4519d78d58be3879937fe2ed2cef68b22404060",
"src/relay/fs-stream-registry.ts": "d723dd0b6419a7937225bdacbc572ef004c49d850cbc616e9168e48f5e63b8fd",
"src/relay/dispatcher-notification-publication.ts": "d21c8575f5f4694a806b0e05cce4a1595f7116220f48d9068eba7c23aed7a546",
"src/relay/fs-handler-utils.ts": "12c54b9647ddc9e4ae9924f65aadf0d00e9553050858beae232c02077a94d93a",
"src/relay/relay-frame-decoder.ts": "7044cd142b21f847ee0dc4aee085fe18434b4bcb34ba1eb8bc1fb48a0ed7919e",
"src/main/ssh/ssh-file-stream-inactivity-deadline.ts": "0554419b22356efa60065b15640d8bf0d8d166bf345e30b492b2305767ea603a",
"src/main/ssh/ssh-file-stream-read-cap.ts": "83b714af0c87da4c855762ffb827f529302ebc426ab5cde59458206a64cb07b1",
"src/main/ssh/relay-protocol.ts": "644aa6f2087b5867d41006bfdcec78ffba693157a2912feb8f82b120f5647b34",
"src/main/ssh/ssh-multiplexer-transport-writer.ts": "433a2cc400b5ed9743871ca03aac06d31a079ef23c42c2a2759f78967312d071",
"src/shared/relay-frame-decoder-contract.ts": "f58279099fe4dbbdfde9e3e038a916ae077e71d8ac6aa871b59a64c6fbaeaad8",
"src/relay/dispatcher-pty-publication.ts": "2a91aeab16069e4d053c453947c5cd5d24c9e9ac4d36f19d23377689313cc851",
"src/main/system-power-lifecycle.ts": "cdeb82463a530123be4d42374925a0b349446437c02bff2c287786864515de11",
"src/relay/dispatcher-contract.ts": "7c397f9d51c0e3eca604e9075cddb6609a5e114284999009e8a86146e766c081",
"src/shared/relay-frame-decoder.ts": "88c0ecbc06b93efe6733094b8c9e4509ab1cbd139f8cb3257e9fb74a0ed39194",
"src/main/ssh/ssh-multiplexer-writer-lane-scheduler.ts": "6173ddd6640a930d0fa586fed91c1adc0d865a33f81af9a1ed5a0817c08e90cc",
"src/shared/pty-write-settlement.ts": "0726aba75f2ef127d41f47a0685fb42daab34fc139d5b9f727079aec88b702e6",
"src/shared/search-subprocess-lines.ts": "b860de84a4081fb86515bb31910f0f1d4b0a6606370490c4f60aa9d2e9d5eee3",
"src/relay/fs-handler-list-files.ts": "cca88ec47512840837c84c63baf7ddfc7c216810cb2a5f63fa2babbeef3ff189",
"src/shared/text-search.ts": "c291574e05874440601a6a043b5d065ca172072f4146568d9c9818829abbc185",
"src/shared/ripgrep-process-availability.ts": "2a8e21ae2c9af146f1f226bdfad1e45916cfb2671fb11aa4586d6a337b8cdec2",
"src/shared/image-file-extensions.ts": "8460080a80e2a09fd64466faee1b87df4d8e4803e33bc2f9dd8b3b031a54d536",
"src/relay/dispatcher-producer-capacity.ts": "ae201a7e09acbcc013410b92868df1e964f95952f8ab3a462d177f488292965b",
"src/relay/dispatcher-producer-transport.ts": "c48087ed5f330311fb438ae3df75ff03bf34df83156f3987b8905598e21780e5",
"src/shared/relay-frame-buffer.ts": "f89fcd33489894c34f79ba8fd5e5634487555a7c34fc8e7c054229ae1c20e0be",
"src/shared/quick-open-path-search.ts": "2556d6c34f50e573b2fe046f7c64f782362b3bda1241098772bd360975910a98",
"src/shared/quick-open-filter.ts": "9a09a02764d15622932083a3e550fc7215c066d30a2868f5d548b9105b780d81",
"src/shared/file-listing-cancellation.ts": "c9e1eed636fa2140071fbc2089ce02f738cce725de5069f04bf307fea611b63d",
"src/relay/dispatcher-rpc-routing.ts": "c11b3ff9096bd4877bba0ebc8a70a63092a00015fde3d8f81f3b8fb54087479b",
"src/relay/dispatcher-client-writer.ts": "9ed3ee7ad2758f2d10cd0a0ba06bf77dc3318317b8e24f0eaa6cfe235fd34be4",
"src/shared/text-search-match-accumulator.ts": "1229c755abc94608e211c6e403d529905f577a991871eadfed5eb18821d6e88b",
"src/shared/json-text-structure-limit.ts": "29ded95f7e054d839dc82d118cb324d2144c70267f293d6b54f188159ada5eb9",
"src/shared/text-search-paths.ts": "a977e1b1591b38d7f08652e3d0a64c95b9379e8339ab9d26d6325f7ec90fcda3",
"src/shared/text-search-glob-patterns.ts": "b34072078cf81813e37c37228b830c974dfadb5284ba608538deb56594d64a69",
"src/shared/search-match-count.ts": "6fde43e886fe142c23aff2b80b3f1ebdf613a083051a11e19d7813363db88f62",
"src/shared/string-utils.ts": "fdb48c18f2f7272ed25949eb74beca182c3336d32681191c4b0a4a077db02c22",
"src/shared/file-name-sort.ts": "5aafddf218c453276dcf1afdccf9cd7f82c50160f5df2dd6d1b2779ce0c0cc4a",
"src/shared/clipboard-text.ts": "50f3155063244d6d12ac1493c80029a75df256c031f986f76da64a94152d0c38",
"src/shared/cross-platform-path.ts": "d72a91065f535824b17f2d3285f45f6d2f40ec2a2ac7ffb4ae17edaa4f9a97f3",
"src/shared/event-loop-yield.ts": "d291b71f09eb88f24a849d0ae310f946deb7f93b0b6b490b6ea5a605ab7a53cf",
"src/shared/utf8-byte-limits.ts": "01574b287b2d6ab0758112887e356c0d270b25db1b72651925ba8329eb4224e6",
"src/relay/dispatcher-frame-codec.ts": "2728b1e9e8465350c23cede5b64287db77a3d90488d8f8b1e5721bc69d7af7a9",
"src/shared/timer-delay.ts": "7e529ed30d1b25521f5d72d7c2a6d05d16efe66faf2619b859d67b330cca767f",
"src/shared/skill-install-failure.ts": "571ffbaad47304bbdef294e6445d70d179db40495005fdd0ce385491666ec4eb",
"src/shared/terminal-unavailable-cause.ts": "b6ce6d4b5b666ef72d8403883d2cc167f2998c524a17155638c261000c424e91",
"src/relay/dispatcher-writer-sink.ts": "12931e899bd882347b72cd03f3690e8d0e94bf2aa1ce9a560112f52cfdc14405",
"src/relay/dispatcher-writer-lane-scheduler.ts": "8b54b850f8aa88944160b796e3b357fbbc618c782590b4baf6444eda77bd5e5e",
"src/relay/dispatcher-writer-drain-arm.ts": "c9f1582197d0bb9ea30793f0c792a176ada3f25fb79e03474a70d084920d0416",
"src/relay/dispatcher-writer-admission.ts": "12e05ba04de5687c0a0db1e44eddf342cb0c21b9653eef2aeff7b5ccd2039797",
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a",
"src/relay/dispatcher-capacity-signals.ts": "125a94f04f8b3102956231dc800281ed7ee9bb538b2b93f005825c2dd59e5c63",
"src/shared/runtime-capability-degradation.ts": "fc9a5d3814c390c1296ecf72d5ef317b8b8624ea2578e4ceb2b32ee642b13a3f",
"src/relay/dispatcher-client-lifecycle.ts": "6884223ddb2be888cfd437ca1490e14a4c95655c5c831e6812ce4a7876e9c18b",
"src/relay/dispatcher-client-state.ts": "8cdabcb8df6b16b7cc84d8a33f5171cca1807b6d9dea4b374e3c2bb4fc10bfdb",
"src/relay/legacy-relay-publication-ledger.ts": "153926b90d370669d91fd9a59d246f63ed0c01f2aec6e8b7a890a0d0ad7378ba",
"src/relay/client-request-aborts.ts": "a4f458ce767c5315aa01fa9cc8c0a3008dcb445481a401462f14ec3dbbd9e20c"
},
"observedReaderSha256": "333b0ba796edbd0a07b483cc65fb9f0c46f6f5488361bce08c488ea72543825a",
"controls": [
{
"name": "held-metadata-foreign-history",
"readers": 4,
"entries": [0, 0, 0, 0],
"wrappers": 0,
"uniqueParams": 0,
"logicalBase64BytesByUniqueParams": 0,
"sharedAcrossReaders": false,
"decodedTransferBytes": 33554432,
"peakRegisteredStreams": 1,
"maxConcurrentStreams": 16,
"ackWindow": 4,
"ackCount": 128,
"observedHeapDelta": -663256,
"released": true
},
{
"name": "ordinary-completion",
"passed": true
},
{
"name": "transport-disposal",
"passed": true
},
{
"name": "metadata-request-deadline",
"milliseconds": 30000,
"relayContextAborted": true,
"released": true
},
{
"name": "unpaced-relay",
"passed": true
},
{
"name": "real-pump-credit-window",
"chunksBeforeAck": 4,
"totalChunks": 6
},
{
"name": "actual-stream-capacity",
"slots": 16,
"rejectedSeventeenth": true,
"admittedAfterCompletion": true
},
{
"name": "saturated-writer-metadata-order",
"wireOrder": ["probe.prime", "response", "fs.streamChunk", "fs.streamChunk", "fs.streamEnd"]
},
{
"name": "same-turn-response-and-own-frames",
"passed": true
},
{
"name": "canonical-crlf-source-control",
"reads": 66,
"passed": true
}
],
"artifactHashes": {
"sources.cjs": "790ef573e61fbb7f68741d1e1cfb0e4b4c79a2dde4292e7ec9dc8dcf1d94940f",
"relay-fixture.mjs": "9856e17d83b812fb6b6717cf8207ccc808165c517df8d5c76bc020e0e6f51079",
"scenario.test.mjs": "7a619c631403d0ab03610171fc6109a9b52cde2d23b9d022354c3d6bb1b154af",
"vitest.config.mjs": "4ef63c6eba6d8e443f18f787bceb0b1f40b90ee750d63a3b27239ed05bf5d849",
"before.config.mjs": "a0a8ec50388d194d60cdf7019bba18fffb2c4691404924c3675757d3b3984962",
"fix.patch": "9681d47063d4b76bc4f9567eacf5dddb3ec221b715ce0bedddfcb493e0558fc6",
"main-context.patch": "4ef0bf173e6575a548b1d217248432fedbcfd68bcb350980e73987df3aba81e9",
"source-versions.json": "7df5afb4523280364b1eecaf465131ac94822fbb868dc4aaddb42586dff9f21d"
}
}
@@ -0,0 +1,34 @@
# Transcript catchup can outlive host teardown
Host teardown stops TUI transcript catchup before draining in-flight handoffs. Previously, catchup setup registered its state only after asynchronous path resolution and stored its unsubscribe function only after asynchronous subscription acquisition. Teardown could miss either resource. A handoff that had not entered preparation yet could also start a watcher after `stopAll`. Actual-host tests reproduced a surviving watcher after the host session was removed. Stopping an already acquired watcher before its first snapshot instead left preparation waiting indefinitely for that snapshot.
## Ownership fix
Catchup now registers its state before its first await and owns an abort controller throughout setup. It passes the existing resolver/subscriber cancellation signal, releases late subscriptions, settles the initial-ready wait on stop, and preserves a newer same-session acquisition. `stopAll` permanently closes this host's admission; ordinary per-session `stop` still permits a replacement.
Preparation returns its signal internally so the handoff checks cancellation immediately before and after launching a TUI. A dedicated internal cancellation error, while no TUI owner or process identity has been committed, releases the unused reservation through the existing fenced `abandonStoredAgentSessionHandoffAttempt` transition. If launch returns after cancellation with an owner, the existing proven cleanup path is invoked; if cleanup is unavailable or fails, ownership is retained and manual recovery remains required. It leaves a recoverable native lease without acquiring a replacement. This distinction matters: ordinary preparation failure invokes native recovery, and a delayed replacement acquisition can finish after the five-second teardown drain. Ordinary read failures retain that recovery behavior. Canceled recovery of a live TUI stops without retrying or relabeling its live lease, and settles any original durable operation that was still pending.
These are internal lifecycle changes. They add no wire type and infer no remote process death. A launch that remains in flight beyond the bounded teardown drain, and boundary/import I/O already admitted before cancellation, remain outside this change's cancellation guarantee.
## Reproduce
```sh
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/tui-transcript-acquisition/reproduce.mjs
```
The script runs seven tests through the actual host, handoff coordinator, durable record store, journal, and transcript watcher. Real file resolution, watcher installation, and initial read are paused at explicit asynchronous boundaries; provider processes use the existing fake adapter/transport. No real shell or app window launches.
`fix.patch` is reversed inside a temporary Vite transform for the baseline. The new internal error declaration remains available to the same assertions; it does not change baseline control flow. Source hashes and exact failing cases are recorded in `results.json`. Each runner uses a 512 MiB old-space limit, a 90-second deadline, and the repository's cross-platform `runProcess`. The proof requires the fixed runner to exit successfully in addition to matching its seven-pass/zero-fail report. Temporary runner/configuration files and acquired watchers are cleaned up.
| Version | Passed | Failed |
| ---------- | -----: | -----: |
| Before fix | 1 | 6 |
| With fix | 7 | 0 |
The five preparation cases cover resolution, subscription return, initial snapshot, admission after teardown, and a completed preparation whose caller has not resumed. The recovery case preserves the live TUI lease. The control delays native acquisition after an ordinary resolver error and verifies the original error and recovery behavior. Six additional ownership tests cover overlapping prepare/recover replacements, per-session restart, repeated shutdown, and the signal returned when no supported record is available. Existing catchup tests preserve live appends and restart gap replay.
Two additional handoff regressions cover a TUI launch returning after cancellation and cancellation during recovery of a pending durable operation. Both fail against the original PR head and pass with the review fix. A late owner is stopped through the existing proven-cleanup contract, then the reservation is abandoned without acquiring a replacement native owner. If cleanup is unavailable or cannot prove the owner stopped, the existing manual-recovery path retains ownership instead. Recovery cancellation marks the original operation failed without relabeling the live TUI lease.
## Version and attribution
Named-path reads confirm the same setup gaps, unguarded forward launch, and stop-before-drain ordering in `v1.4.198`. In that tag the teardown phases are inline in `structured-agent-session-host.ts:254`; current source extracts them into `structured-agent-session-host-teardown.ts`. The executable proof compares current source before/after this fix. It establishes an execution-host watcher retaining path present in the reported version, without proving that #19831 or #19768 exercised this teardown race or explaining either report's memory magnitude.
@@ -0,0 +1,294 @@
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts
index a73e8b2111..b9559868f4 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts
@@ -12,7 +12,10 @@ import type {
StructuredAgentSessionHandoffFlowContext,
StructuredTuiOwner
} from './structured-agent-session-handoff-types'
-import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types'
+import {
+ StructuredTuiCatchupStoppedError,
+ StructuredTuiLaunchCleanupError
+} from './structured-agent-session-handoff-types'
export async function handoffStructuredSessionToTui(
context: StructuredAgentSessionHandoffFlowContext,
@@ -75,7 +78,8 @@ export async function handoffStructuredSessionToTui(
let owner: StructuredTuiOwner | null = null
let processIdentityCommitted = false
try {
- await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence)
+ const prepared = await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence)
+ prepared?.throwIfAborted()
owner = await deps.transport!.launchTui({
record,
fence: record.lease.runtimeFence,
@@ -91,6 +95,7 @@ export async function handoffStructuredSessionToTui(
processIdentityCommitted = true
}
})
+ prepared?.throwIfAborted()
if (!processIdentityCommitted) {
await deps.store.commitProcessIdentity({
sessionId,
@@ -128,6 +133,16 @@ export async function handoffStructuredSessionToTui(
)
}
}
+ if (error instanceof StructuredTuiCatchupStoppedError && (owner || !processIdentityCommitted)) {
+ await abandonStoredAgentSessionHandoffAttempt(deps.store, {
+ sessionId,
+ expectedFence: record.lease.runtimeFence,
+ operationId,
+ recoverableRuntimeKind: 'native',
+ now: deps.now()
+ })
+ throw error
+ }
await recoverNativeAfterTuiFailure(context, sessionId, operationId)
throw error
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts
index c16ac68122..3bf0bc08e8 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts
@@ -96,3 +96,16 @@ export async function persistReprovedTuiOwner(
})
}
}
+
+export async function startRecoveredTuiCatchup(
+ input: StructuredAgentSessionRestartAccess,
+ record: AgentSessionRecord
+): Promise<void> {
+ const prepared = await input.deps.recoverTuiHistoryCatchup?.(
+ record.sessionId,
+ record.lease.runtimeFence
+ )
+ prepared?.throwIfAborted()
+ await input.deps.activateTuiHistoryCatchup?.(record.sessionId)
+ prepared?.throwIfAborted()
+}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts
index 13a26f2a7a..a6ad92d91e 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts
@@ -12,10 +12,12 @@ import {
structuredTuiRecoveryProofIsAdmissible
} from './structured-agent-session-handoff-status'
import type { StructuredTuiOwner } from './structured-agent-session-handoff-types'
+import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types'
import {
persistReprovedTuiOwner,
recoverTuiOwnerOrContinue,
recoverUnavailableTuiAsNative,
+ startRecoveredTuiCatchup,
type StructuredAgentSessionRestartAccess
} from './structured-agent-session-handoff-restart-tui'
@@ -57,6 +59,15 @@ export async function restoreStructuredAgentSessionHandoff(
}
return
} catch (error) {
+ if (error instanceof StructuredTuiCatchupStoppedError) {
+ if (operationId) {
+ await input.deps.store.recordOperationOutcome({
+ operationId,
+ outcome: { status: 'failed', code: 'agent_session_handoff_failed' }
+ })
+ }
+ throw error
+ }
lastError = error
if (attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt))
@@ -278,14 +289,6 @@ async function restoreProving(input: RestartAccess, record: AgentSessionRecord):
await continueHandoff(input, stopped)
}
-async function startRecoveredTuiCatchup(
- input: RestartAccess,
- record: AgentSessionRecord
-): Promise<void> {
- await input.deps.recoverTuiHistoryCatchup?.(record.sessionId, record.lease.runtimeFence)
- await input.deps.activateTuiHistoryCatchup?.(record.sessionId)
-}
-
async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise<void> {
const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native'
const operationId = record.lease.handoffOperationId!
diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts
index cc343c9231..10ce2416a3 100644
--- a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts
+++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts
@@ -18,12 +18,15 @@ import {
type NativeChatTranscriptSubscription
} from '../transcript-watch'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
+import { StructuredTuiCatchupStoppedError } from './structured-agent-session-handoff-types'
import {
readStructuredTuiTranscriptBoundary,
writeStructuredTuiTranscriptBoundary
} from './structured-tui-transcript-boundary'
type CatchupState = {
+ controller: AbortController
+ initialReady: (() => void) | null
active: boolean
fence: number
agent: AgentSessionHandleProvider
@@ -35,6 +38,7 @@ type CatchupState = {
export class StructuredTuiTranscriptCatchup {
private readonly states = new Map<string, CatchupState>()
+ private readonly teardown = new AbortController()
constructor(
private readonly input: {
@@ -47,15 +51,16 @@ export class StructuredTuiTranscriptCatchup {
}
) {}
- async prepare(sessionId: string, fence: number): Promise<void> {
- await this.start(sessionId, fence, false)
+ async prepare(sessionId: string, fence: number): Promise<AbortSignal> {
+ return this.start(sessionId, fence, false)
}
- async recover(sessionId: string, fence: number): Promise<void> {
- await this.start(sessionId, fence, true)
+ async recover(sessionId: string, fence: number): Promise<AbortSignal> {
+ return this.start(sessionId, fence, true)
}
- private async start(sessionId: string, fence: number, recovering: boolean): Promise<void> {
+ private async start(sessionId: string, fence: number, recovering: boolean): Promise<AbortSignal> {
+ this.teardown.signal.throwIfAborted()
this.stop(sessionId)
const record = this.input.store.getRecord(sessionId)
const head = record?.providerHandleChain.at(-1)
@@ -64,7 +69,7 @@ export class StructuredTuiTranscriptCatchup {
!head ||
(head.handle.provider !== 'codex' && head.handle.provider !== 'claude')
) {
- return
+ return this.teardown.signal
}
const agent = head.handle.provider
const providerSessionId = agent === 'claude' ? head.handle.sessionId : head.handle.threadId
@@ -73,17 +78,9 @@ export class StructuredTuiTranscriptCatchup {
agent === 'claude'
? { claudeProjectsDir: join(record.accountHome.path, 'projects') }
: { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] }
- const boundary = recovering
- ? await readStructuredTuiTranscriptBoundary(journal.directory)
- : null
- const filePath = await resolveSessionFilePath(agent, providerSessionId, {
- ...transcriptOptions,
- ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {})
- })
- let initialReady: (() => void) | null = null
- let baselineOffset = 0
- const ready = filePath ? new Promise<void>((resolve) => (initialReady = resolve)) : null
const state: CatchupState = {
+ controller: new AbortController(),
+ initialReady: null,
active: false,
fence,
agent,
@@ -95,20 +92,42 @@ export class StructuredTuiTranscriptCatchup {
const receive = (messages: NativeChatMessage[]) => this.receive(sessionId, state, messages)
this.states.set(sessionId, state)
try {
- state.subscription = await subscribeNativeChatTranscript({
+ const signal = state.controller.signal
+ const boundary = recovering
+ ? await readStructuredTuiTranscriptBoundary(journal.directory)
+ : null
+ signal.throwIfAborted()
+ const filePath = await resolveSessionFilePath(
agent,
- sessionId: providerSessionId,
- ...transcriptOptions,
- ...(filePath ? { filePath, initialLimit: 0 } : {}),
- onInitialSnapshot: (messages, _hasMore, beforeOffset) => {
- baselineOffset = beforeOffset
- receive(messages)
- initialReady?.()
- initialReady = null
+ providerSessionId,
+ {
+ ...transcriptOptions,
+ ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {})
},
- onAppend: receive
- })
+ signal
+ )
+ signal.throwIfAborted()
+ let baselineOffset = 0
+ const ready = filePath ? new Promise<void>((resolve) => (state.initialReady = resolve)) : null
+ state.subscription = await subscribeNativeChatTranscript(
+ {
+ agent,
+ sessionId: providerSessionId,
+ ...transcriptOptions,
+ ...(filePath ? { filePath, initialLimit: 0 } : {}),
+ onInitialSnapshot: (messages, _hasMore, beforeOffset) => {
+ baselineOffset = beforeOffset
+ receive(messages)
+ state.initialReady?.()
+ state.initialReady = null
+ },
+ onAppend: receive
+ },
+ signal
+ )
+ signal.throwIfAborted()
await ready
+ signal.throwIfAborted()
if (!recovering) {
await writeStructuredTuiTranscriptBoundary(journal.directory, {
providerSessionId,
@@ -134,13 +153,21 @@ export class StructuredTuiTranscriptCatchup {
if (!imported.ok) {
throw new Error(imported.error)
}
+ signal.throwIfAborted()
this.input.reset(sessionId, fence)
}
+ signal.throwIfAborted()
+ return signal
} catch (error) {
+ const stopped = state.controller.signal.aborted
if (this.states.get(sessionId) === state) {
- this.states.delete(sessionId)
+ this.stop(sessionId)
+ } else {
+ state.subscription?.unsubscribe()
+ }
+ if (stopped) {
+ state.controller.signal.throwIfAborted()
}
- state.subscription?.unsubscribe()
throw error
}
}
@@ -197,10 +224,16 @@ export class StructuredTuiTranscriptCatchup {
stop(sessionId: string): void {
const state = this.states.get(sessionId)
this.states.delete(sessionId)
+ state?.controller.abort(new StructuredTuiCatchupStoppedError())
+ state?.initialReady?.()
+ if (state) {
+ state.initialReady = null
+ }
state?.subscription?.unsubscribe()
}
stopAll(): void {
+ this.teardown.abort(new StructuredTuiCatchupStoppedError())
for (const sessionId of this.states.keys()) {
this.stop(sessionId)
}
@@ -0,0 +1,151 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { applyPatch, parsePatch, reversePatch } from 'diff'
import { build } from 'esbuild'
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
}
const root = fileURLToPath(new URL('../../../', import.meta.url))
const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8')
const beforeSources = {}
const sourceHashes = {}
for (const parsed of parsePatch(patch)) {
const path = parsed.newFileName.replace(/^b\//, '')
const absolute = resolve(root, path)
const current = await readFile(absolute, 'utf8')
const before = applyPatch(current, reversePatch(parsed))
if (before === false) {
throw new Error(`Source changed; review the proof patch: ${path}`)
}
beforeSources[absolute.replaceAll('\\', '/')] = before
sourceHashes[path] = {
before: createHash('sha256').update(before).digest('hex'),
after: createHash('sha256').update(current).digest('hex')
}
}
for (const path of [
'src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts',
'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts',
'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts'
]) {
sourceHashes[path] = {
current: createHash('sha256')
.update(await readFile(resolve(root, path)))
.digest('hex')
}
}
const scratch = await mkdtemp(join(tmpdir(), 'orca-tui-transcript-acquisition-'))
const require = createRequire(import.meta.url)
let runnerModuleId
try {
const runnerPath = join(scratch, 'run-process.cjs')
await build({
absWorkingDir: root,
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
outfile: runnerPath,
bundle: true,
platform: 'node',
format: 'cjs',
logLevel: 'silent'
})
runnerModuleId = require.resolve(runnerPath)
const { runProcess } = require(runnerModuleId)
const baselineConfig = join(scratch, 'before.config.mjs')
const fixedConfig = join(scratch, 'after.config.mjs')
const includes = [
'src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts'
]
const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href)
await writeFile(
baselineConfig,
`import base from ${configImport};
const beforeSources = ${JSON.stringify(beforeSources)};
export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{
name: 'tui-transcript-acquisition-before-fix', enforce: 'pre',
transform(_code, id) {
const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]];
return before === undefined ? null : {code: before, map: null};
}
}]};\n`
)
await writeFile(
fixedConfig,
`import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n`
)
async function run(label, config) {
const report = join(scratch, `${label}.json`)
const result = await runProcess({
program: process.execPath,
args: [
resolve(root, 'node_modules/vitest/vitest.mjs'),
'run',
'--config',
config,
'--reporter=json',
`--outputFile=${report}`
],
cwd: root,
env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=512' },
timeoutMs: 90_000,
maxOutputBytes: 4 * 1024 * 1024
})
let parsed
try {
parsed = JSON.parse(await readFile(report, 'utf8'))
} catch (error) {
throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error })
}
return {
exitCode: result.code,
passed: parsed.numPassedTests,
failed: parsed.numFailedTests,
failedCases: parsed.testResults.flatMap((suite) =>
suite.assertionResults
.filter((test) => test.status === 'failed')
.map((test) => test.fullName)
)
}
}
const before = await run('before', baselineConfig)
const after = await run('after', fixedConfig)
const passed =
before.failed === 6 &&
before.passed === 1 &&
before.passed + before.failed === 7 &&
after.exitCode === 0 &&
after.passed === 7 &&
after.failed === 0
console.log(
JSON.stringify(
{
comparison:
'Actual structured host teardown, record store, journal, and transcript watcher; baseline reverses catchup/forward/restart behavior through a temporary Vite transform',
sourceHashes,
before,
after,
passed
},
null,
2
)
)
if (!passed) {
process.exitCode = 1
}
} finally {
if (runnerModuleId) {
delete require.cache[runnerModuleId]
}
await rm(scratch, { recursive: true, force: true })
}
@@ -0,0 +1,50 @@
{
"comparison": "Actual structured host teardown, record store, journal, and transcript watcher; baseline reverses catchup/forward/restart behavior through a temporary Vite transform",
"sourceHashes": {
"src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts": {
"before": "c9bb6fbf8ca3fc3fad815f35a21c73e392dd6be267335984deb0b5c9319210f1",
"after": "99204872e4432ea841be493012c23b00b67fedabe07a83332c995dec632839cc"
},
"src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts": {
"before": "fcfcbd821816f33d1cf8bb71e6ecb40b03d4139affe8629f5baaa0a45f423921",
"after": "58a1b23f9390234e39bdb9681e43e9b32fd4d741a4242101a8d25e85a7001c6e"
},
"src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts": {
"before": "8f2dc4f31fd2f96f3e9393afcc0826b712591c5d3bfa80965113e63be65f69ab",
"after": "73461453fba97bd0630471a224fc718ac2ce497c18fc95afb2ee264e7e42f791"
},
"src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts": {
"before": "36085d52e44152c7d8906ac2691242e8e31e54511fc908510c0d3aee10615973",
"after": "2d0dc6dcfbfba666a0bdebb229b78706c8a137b8427aa2a8b8bc679f0d43749b"
},
"src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts": {
"current": "225283eaf80f976fcad35554b330ad24e81cd4c1dd275996dc471c53de46ba67"
},
"src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown.test.ts": {
"current": "cc8be5277db229dcf52d1c72bfddfc86b2f07fd2f77eba3f11af01d1877f2604"
},
"src/main/native-chat/agent-session-wire/structured-tui-transcript-teardown-test-fixture.ts": {
"current": "d186aeab78e31f0aa493f92d5f472708e81791670e82637e37481ebca9918756"
}
},
"before": {
"exitCode": 1,
"passed": 1,
"failed": 6,
"failedCases": [
"cancels transcript acquisition during host teardown at resolve",
"cancels transcript acquisition during host teardown at subscribe",
"cancels transcript acquisition during host teardown at initial-ready",
"cancels transcript acquisition during host teardown at before-prepare",
"cancels transcript acquisition during host teardown at after-prepare",
"cancels recovered TUI catchup without relabeling the live owner or retrying"
]
},
"after": {
"exitCode": 0,
"passed": 7,
"failed": 0,
"failedCases": []
},
"passed": true
}
+21 -2
View File
@@ -24,12 +24,31 @@ truth. Everything else is derived from it by
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
upstream commit the published tarball was built from.
`@xterm/addon-webgl`, `@xterm/addon-search` and `@xterm/addon-serialize` are
`@xterm/addon-webgl`, `@xterm/addon-search`, `@xterm/addon-serialize` and `@xterm/addon-image` are
generated the same way, from their own source patches under
`config/patches/xterm-src/`. Their entries differ only in `packageDir` and build
steps; everything below applies to all four. `@xterm/addon-ligatures` is the one
steps; everything below applies to all five. `@xterm/addon-ligatures` is the one
patch still written by hand — see [Known Gaps](#known-gaps).
The image patch bounds pending Kitty decoders by their maximum WASM capacity
and caps transmitted image blobs by byte size. Both use the configured storage
budget; upstream's displayed-pixel budget does not cover these allocations.
Byte-budget eviction drops unplaced payloads first, so a new upload cannot erase a
visible image while abandoned blobs still hold budget; displayed images go only
when that is not enough, because the cap is a hard bound. The incoming image is
always stored, so the cap overshoots by at most one payload rather than dropping
an image the protocol already acked as `OK`. Orca uses fixed 32 MB storage and
8 MiB sequence limits, not arbitrary addon configurations.
`config/scripts/xterm-image-memory-contract.test.mjs` exercises the installed
bundle with unfinished uploads, chunk continuation, both eviction orders and
disposal.
The patch also bounds decompression before joining decoded chunks, validates PNG
dimensions before native decoding, and closes stale asynchronous image results
after reset, disable or disposal. `config/scripts/xterm-image-lifecycle-contract.test.mjs`
exercises those boundaries against the installed addon. Font zoom scales visible
tiles without creating enlarged full-image canvases;
`config/scripts/xterm-image-resize-contract.test.mjs` checks allocation and tile mapping.
## Rules
1. Never edit `config/patches/@xterm__*@<version>.patch`. Edit the source
+30 -9
View File
@@ -1,14 +1,35 @@
import { useLocalSearchParams } from 'expo-router'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { HostScreenView } from '../../../src/host-screen/host-screen-view'
import {
type HostScreenProps,
useHostScreenController
} from '../../../src/host-screen/use-host-screen-controller'
import { HostScreen } from '../../../src/host-screen/HostScreen'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
import { useMobileWebShellEnabled } from '../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
export function HostScreen(props: HostScreenProps = {}) {
const controller = useHostScreenController(props)
return <HostScreenView controller={controller} />
/**
* The worktree list, from the desktop's bundle or from this app.
*
* The shell decides, not this switch: it renders the page only for a route the bundle lists with
* grants this app implements, and answers `native-route` otherwise, which is what `fallback` is.
* So the two ways to stay native are a flag that is off and a negotiation that said no, and the
* second one covers every host whose desktop is older than the page.
*
* `enabled === null` is the flag read still settling, and it renders the native screen: a store
* build never reaches storage at all, so that is the only frame it ever paints here.
*/
function HostListScreen() {
const { hostId } = useLocalSearchParams<{ hostId: string }>()
const enabled = useMobileWebShellEnabled()
if (enabled !== true || !hostId) {
return <HostScreen />
}
return (
<MobileWebShellScreen
hostId={hostId}
route={{ pathname: `/h/${hostId}` }}
fallback={<HostScreen />}
/>
)
}
// On wide layouts the sidebar hosts the list, so this route is just the empty detail pane.
@@ -17,5 +38,5 @@ export default function HostWorktreeRoute() {
if (isWideLayout) {
return <WorkspaceDetailPlaceholder />
}
return <HostScreen />
return <HostListScreen />
}
+20
View File
@@ -0,0 +1,20 @@
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { HostScreen } from '../../../src/host-screen/HostScreen'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
/**
* Web sibling for the worktree list.
*
* This page is what the shell renders for this route, so there is no shell to mount here and no
* flag to read: the switch already happened natively. Its native file also reaches
* OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a
* browser, and one throwing route module takes the whole bundle down because the manifest imports
* them all.
*/
export default function HostWorktreeRoute() {
const { isWideLayout } = useResponsiveLayout()
if (isWideLayout) {
return <WorkspaceDetailPlaceholder />
}
return <HostScreen />
}
+59
View File
@@ -0,0 +1,59 @@
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import { Redirect, useLocalSearchParams } from 'expo-router'
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
import { useMobileWebShellEnabled } from '../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
import { colors } from '../../../src/theme/mobile-theme'
/**
* The hybrid shell route, dark behind a development-only flag.
*
* One of the two callers of `useMobileWebShellEnabled`. With the flag off — which is every store build,
* since the only writer is the `__DEV__` Troubleshoot toggle — this redirects and the screen is
* never constructed, so nothing is fetched, written or swept. It sits under `app/h/[hostId]` so
* `HostProtocolGate` in that group's layout still owns the `desktop-too-old` wall above it.
*
* Reachable by deep link and from the developer row only; no screen links here.
*/
export default function MobileWebShellRoute() {
const { hostId } = useLocalSearchParams<{ hostId: string }>()
const enabled = useMobileWebShellEnabled()
if (enabled === null) {
// A redirect fired before the read settles would bounce a flag that is on, and a screen mounted
// before it settles would fetch on a flag that is off. Neither, until it is known.
return (
<View style={styles.pending}>
<ActivityIndicator color={colors.textSecondary} accessibilityLabel="Checking host" />
</View>
)
}
if (!enabled || !hostId) {
return <Redirect href={`/h/${hostId ?? ''}`} />
}
// The screen the page stands in for. The document is served at `/`, which matches no route in
// the tree the page carries, so this is the only thing that tells it which one to open.
//
// The fallback is a redirect rather than the native screen: this route exists only to open the
// page deliberately, so a bundle that does not list the worktree list has nothing to show here
// and the host route is where the list actually lives.
//
// Encoded, not interpolated raw: `hostId` arrives decoded from the URL, so one carrying `?`, `#`
// or whitespace would build a pathname the page refuses and never mount anything. The page
// decodes it back when it matches `[hostId]`, so the screen it opens is the same one.
return (
<MobileWebShellScreen
hostId={hostId}
route={{ pathname: `/h/${encodeURIComponent(hostId)}` }}
fallback={<Redirect href={`/h/${hostId}`} />}
/>
)
}
const styles = StyleSheet.create({
pending: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgBase
}
})

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